feat(ai-character): 수정 요청 변경 필드만 전송

This commit is contained in:
Yu Sung
2026-08-06 23:47:09 +09:00
parent 2a04955bd7
commit dcc344764b
18 changed files with 1695 additions and 120 deletions

View File

@@ -0,0 +1,213 @@
# 수정 요청 변경 필드 전송 API Contract
## 1. 공통 계약
이 문서는 기존 [AI 캐릭터 관리자 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)의 endpoint·DTO를 바꾸지 않고, 프론트엔드가 수정 request를 구성하는 규칙을 구체화한다. 이 문서와 정식 OpenAPI가 충돌하면 정식 OpenAPI의 field type·nullable·response·error 계약을 우선하고 이 문서는 payload 선택 규칙만 소유한다.
### 1.1 유지되는 항목
- 기존 `PUT` method와 path를 유지한다.
- 기존 bearer 인증, `Accept-Language`, 성공 envelope, 오류 status·key를 유지한다.
- 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈는 `multipart/form-data`를 유지한다.
- multipart JSON part 이름은 `request`, MIME은 `application/json`이다.
- FanTalk 답글은 `application/json` body를 유지한다.
### 1.2 변경 판정
1. 상세·목록 응답으로 form을 초기화할 때 수정 기준값을 보존한다.
2. 현재 form 값과 기준값에 같은 기존 직렬화 규칙을 적용한다.
3. 문자열 trim, 빈 optional 값의 `null` 변환, 배열·객체 배열 변환 후 필드별 값을 비교한다.
4. 값이 다른 field만 request object에 포함한다.
5. 사용자가 값을 바꾼 뒤 기준값으로 되돌리면 해당 field를 생략한다.
비교 대상은 각 기능의 update DTO field다. route ID, 조회 전용 field, 서버 계산값과 수정 화면에 없는 field를 request에 복사하지 않는다.
### 1.3 생략, `null`, falsy 값
| 표현 | 의미 | 예시 |
|---|---|---|
| key 생략 | 미변경 | `{ "title": "새 제목" }`에는 `detail` 변경 없음 |
| `null` | 해당 DTO가 허용하는 기존 값 삭제 | `{ "writer": null }` |
| `false` | boolean 값을 false로 변경 | `{ "isAdult": false }` |
| `0` | 숫자 값을 0으로 변경 | `{ "price": 0 }` |
| 배열·객체 배열 | 해당 필드 전체의 새 값 | `{ "publishedDaysOfWeek": ["RANDOM"] }` |
`false`, `0`, 빈값 삭제용 `null`은 falsy 값이라는 이유로 생략하지 않는다.
### 1.4 변경 없음
- 변경 field와 교체 file이 모두 0개면 저장 control은 native `disabled` 상태다.
- disabled 상태에서는 API helper를 호출하지 않는다.
- 빈 JSON 또는 빈 multipart mutation을 전송하지 않는다.
### 1.5 파일 part
| 상태 | 파일 part | `request` JSON |
|---|---|---|
| 텍스트 field만 변경 | 생략 | 변경 field만 포함 |
| 파일과 field 변경 | 새 파일 1개 | 변경 field만 포함 |
| 파일만 변경 | 새 파일 1개 | `{}` |
| 변경 없음 | 요청 자체 없음 | 요청 자체 없음 |
기존 파일을 선택하지 않으면 서버의 현재 파일을 유지한다. 파일 삭제 기능은 이 계약에 추가하지 않는다.
## 2. 기능별 계약
### 2.1 AI 캐릭터
`PUT /api/v2/admin/ai-characters/{characterId}`
- Content-Type: `multipart/form-data`
- optional file part: `image`
- JSON part: `request`
- 비교 가능 field: `name`, `systemPrompt`, `description`, `age`, `gender`, `mbti`, `speechPattern`, `speechStyle`, `appearance`, `originalTitle`, `originalLink`, `originalWorkId`, `characterType`, `tags`, `hobbies`, `values`, `goals`, `relationships`, `personalities`, `backgrounds`, `memories`
- 금지 field: `region`, 일반 수정의 `isActive`
- 기존 예외: 원작 미선택·선택 해제의 `originalWorkId`는 현재 serializer 계약대로 key를 생략하며, 원작 연결 해제 기능은 추가하지 않는다.
이름만 변경:
```json
{
"name": "루나 수정"
}
```
태그를 모두 삭제:
```json
{
"tags": null
}
```
### 2.2 오디오 콘텐츠
`PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}`
- Content-Type: `multipart/form-data`
- optional file part: `coverImage`
- JSON part: `request`
- 수정 화면 비교 field: `title`, `detail`, `tags`, `price`
- 일반 수정 금지 field: `isActive`
- 화면에 없는 `isAdult`, `isPointAvailable`, `isCommentAvailable`은 상세 응답에서 복사하지 않는다.
상세 설명만 변경:
```json
{
"detail": "수정한 상세 설명"
}
```
가격만 무료로 변경:
```json
{
"price": 0
}
```
### 2.3 커뮤니티 게시글
`PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}`
- Content-Type: `multipart/form-data`
- optional file part: `postImage`
- JSON part: `request`
- 수정 저장 비교 field: `content`, `isCommentAvailable`, `isAdult`
- 수정 저장 금지 field: `isFixed`, `isActive`
- `isFixed` 전환과 soft delete는 기존 전용 action payload를 유지한다.
내용만 변경:
```json
{
"content": "수정한 게시글 내용"
}
```
댓글 허용만 끄기:
```json
{
"isCommentAvailable": false
}
```
### 2.4 시리즈
`PUT /api/v2/admin/ai-characters/{characterId}/series/{seriesId}`
- Content-Type: `multipart/form-data`
- optional file part: `image`
- JSON part: `request`
- 비교 field: `title`, `introduction`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, `writer`, `studio`
- 일반 수정 금지 field: `isActive`, create-only `keyword`
제목만 변경:
```json
{
"title": "달빛 상담 시리즈 수정"
}
```
작가를 삭제:
```json
{
"writer": null
}
```
### 2.5 FanTalk 답글
`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`
- Content-Type: `application/json`
- 비교 field: `content`
- 일반 답글 수정 금지 field: `isActive`
답글 변경:
```json
{
"content": "수정한 답글입니다."
}
```
`content`가 기존 답글과 같으면 수정 button은 disabled이고 request를 보내지 않는다.
## 3. 응답과 오류
응답과 오류 계약은 변경하지 않는다.
| 기능 | 성공 data |
|---|---|
| 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈 | 기존 `null` success data |
| FanTalk 답글 | 기존 `FanTalkListItem` update response |
- validation, 401, 403, 404, 406, 415, 500 처리는 정식 OpenAPI와 기존 공통 API client를 따른다.
- 부분 request 도입을 이유로 새로운 status, `errorProperty` 또는 자동 retry를 만들지 않는다.
## 4. Contract 검증 예시
| 시나리오 | 필수 assertion |
|---|---|
| title만 변경 | request key가 `title` 하나다. |
| detail만 변경 | request key가 `detail` 하나다. |
| boolean을 false로 변경 | 해당 key와 `false`가 존재한다. |
| nullable field 삭제 | 해당 key와 `null`이 존재한다. |
| 변경 후 원복 | 저장 disabled, mutation 0건이다. |
| 파일만 변경 | 파일 part 1개, request `{}`다. |
| 파일 미변경 | 파일 part가 없다. |
## 5. 범위 밖 mutation 회귀 계약
다음 기존 요청은 payload 최적화 대상이 아니며 현재 contract를 유지한다.
- 캐릭터·오디오 콘텐츠·시리즈 비활성화: `{ "isActive": false }`
- 커뮤니티 게시글 고정 전환: `{ "isFixed": boolean }`
- 커뮤니티 게시글 soft delete: 기존 `{ "isActive": false, "isFixed": false }`
- 시리즈 순서 변경: `{ "ids": number[] }`
- FanTalk 원글 삭제: body 없는 `DELETE`

View File

@@ -0,0 +1,619 @@
# 수정 요청 변경 필드 전송 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현·검증 완료 |
| 작성일 | 2026-08-06 |
| 요구사항 기준 | [prd.md](./prd.md) |
| API 기준 | [api-contract.md](./api-contract.md) |
| 현재 Phase | Phase 1 변경 필드 전송 통합 |
| 현재 활성 Goal | 없음 |
## 목표
AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글 수정 시 최종 변경 필드와 교체 파일만 전송하고, 변경이 없으면 저장과 API 요청을 차단한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `7/7` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 삭제하거나 되돌리지 않는다. 후속 수정은 회귀 수정 Task와 새 goal ID를 추가한다.
- 사용자가 token budget을 지정하지 않았으므로 goal에 token budget을 설정하지 않는다.
## 범위
### 포함
- AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글의 일반 수정 payload
- 현재 form 값과 조회 기준값의 field별 비교, nullable 삭제와 미변경 생략 구분
- 이미지·cover·post image만 변경한 multipart의 빈 `request: {}`
- 변경 없음·변경 후 원복 상태의 native disabled 저장 control과 mutation 0건
- 기능별 contract·component test와 mock Chromium E2E 회귀, typecheck·lint·build
### 제외
- 생성 payload, backend DTO와 HTTP method 변경
- 캐릭터·오디오 콘텐츠·시리즈 비활성화 payload
- 커뮤니티 게시글 고정 전환·soft delete, 시리즈 순서 변경, FanTalk 원글 삭제
- 수정 화면 신규 field, image 삭제, 원작 연결 해제 기능
- 새 dependency, 전역 form store, 범용 deep-diff abstraction
## 기술적 제약
- 기술 스택: React 19, TypeScript 6, Zod 4, Vitest 4, Testing Library, Playwright 1.61
- 코드 스타일: TypeScript `strict`를 유지하고 `as any`, `@ts-ignore`, `@ts-expect-error`로 오류를 숨기지 않는다.
- 아키텍처: API helper의 transport·schema 책임은 유지하고, 변경 판정은 각 feature의 form serializer 또는 component에 둔다.
- 데이터: 현재 form과 기준 DTO에 같은 trim·nullable·array serialization을 적용하고 `false`, `0`, `null`을 유효한 변경값으로 보존한다.
- 파일: multipart `request` part는 항상 유지한다. 파일만 변경하면 `{}`, 파일 미변경이면 file part를 생략한다.
- UX: payload와 저장 disabled가 서로 다른 판정을 사용하지 않도록 같은 request 결과에서 `hasChanges`를 계산한다.
- 보안: 인증·권한·resource ownership·민감정보 비기록 정책을 변경하지 않는다.
- 호환성: 기존 기능별 desktop·tablet·mobile capability와 지원 browser를 유지한다.
- 의존성: 새 package를 추가하지 않고 언어·플랫폼 기능과 기존 Zod schema를 사용한다.
- 계약: 제공되지 않은 field, endpoint, response, 오류 status/key를 만들지 않는다.
- mock: 기존 explicit mock mode만 사용하고 production 자동 fallback을 추가하지 않는다.
- 구현: 각 Task는 RED → GREEN → REFACTOR 순서로 진행하고 실제 결과를 Progress에 누적한다.
## Phase 1. 변경 필드 전송 통합
**Phase 결과:** 다섯 수정 기능에서 단일 field 변경, nullable 삭제, file-only 변경과 무변경 상태가 동일한 계약으로 동작한다.
**선행조건:** [prd.md](./prd.md)의 `DIFF-001~005`와 [api-contract.md](./api-contract.md) 확정.
**Phase 완료 조건:** `P1-T1`~`P1-T5``P1-GATE` 완료, 검증 결과와 실제 request 증거를 Progress에 누적.
### 구현 항목
#### Task 1.1 AI 캐릭터 변경 field 직렬화
**Goal 실행 `P1-T1`:** 캐릭터 수정 request가 변경된 profile·optional·repeated field만 포함하고 무변경 저장을 차단한다.
- **시작 조건:** `CHAR-001`, `DIFF-001~005`, `DATA-001~002`, API Contract §1·§2.1 확정.
- **완료 증거:** direct field·nullable·repeated·image-only·무변경 test, focused 회귀, Progress 기록.
- **범위 밖:** create form, region 수정, `isActive`, 원작 연결 해제 backend 의미 변경.
**Files:**
- Modify: `src/features/characters/components/character-optional-field-serialization.ts`
- Modify: `src/features/characters/pages/CharacterEditPage.tsx`
- Test: `src/features/characters/tests/CharacterEditPage.test.tsx`
- Test: `src/features/characters/tests/character-api.test.ts`
**Interfaces:**
- Consumes: `CharacterDetail`, `CharacterOptionalFieldsValue`, `UpdateCharacterParams["request"]`, 기존 `serializeCharacterRequest`.
- Produces: 현재 optional field와 초기 optional field를 비교해 변경 key만 반환하는 `toUpdateCharacterOptionalRequest` 계약, direct field와 합쳐진 변경 전용 request, 동일 request 기반 `hasChanges`.
- 보존: 원작 미선택의 `originalWorkId` key 생략, file part 이름 `image`, `region`·일반 수정 `isActive` 제외.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인**`CharacterEditPage.test.tsx`에 name 단일 변경, optional field `null` 삭제, 반복 field 변경, 최초·원복 disabled, image-only `{}` 시나리오를 추가하고 focused 명령에서 현재 전체 field payload 또는 enabled 저장 때문에 실패하는지 확인한다.
- [x] **GREEN: 최소 구현/통과 확인** — optional serializer가 초기값과 달라진 key만 만들고 page가 direct field와 file을 같은 방식으로 판정하도록 최소 수정해 focused 명령을 통과시킨다.
- [x] **REFACTOR: 정리/회귀 확인** — payload 계산 중복만 제거하고 create serializer·API multipart helper는 유지한 채 Character 전체 회귀를 실행한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts`; `npm run test:run -- src/features/characters`; `npm run typecheck`; `npm run lint`.
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, name 단일 변경 key 1개, 무변경 `PUT` 0건, type·lint 오류 0건.
- **수동 확인:** 1280px에서 캐릭터 이름만 수정한 request part가 `{name}`이고, 원복 시 저장 disabled, image만 교체하면 `image``{}`만 전송된다.
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
#### Task 1.2 오디오 콘텐츠 변경 field 직렬화
**Goal 실행 `P1-T2`:** 오디오 콘텐츠 수정 request가 변경된 title·detail·tags·price와 새 cover image만 포함한다.
- **시작 조건:** `P1-T1` 완료, `AUDIO-001`, `DIFF-001~005`, API Contract §1·§2.2 확정.
- **완료 증거:** 단일 field·price 0·cover-only·무변경 test, 기존 비활성화 회귀, Progress 기록.
- **범위 밖:** create options, audio 원본 교체, theme·release date 수정, 비활성화 payload.
**Files:**
- Modify: `src/features/audio-contents/components/audio-content-form-helpers.ts`
- Modify: `src/features/audio-contents/components/AudioContentForm.tsx`
- Test: `src/features/audio-contents/tests/audio-form-update.test.tsx`
- Test: `src/features/audio-contents/tests/audio-contract.test.ts`
**Interfaces:**
- Consumes: `AudioContentDetail`, `AudioContentUpdateRequest`, 기존 `toUpdateRequest``createAudioContentUpdateBody`.
- Produces: 직렬화된 현재 값과 `audio` 기준값을 비교해 변경 key만 반환하는 `toUpdateRequest`, 같은 request와 `coverImage` 기반 `hasChanges`.
- 보존: file part `coverImage`, `request` part, 일반 update `isActive` 금지, price `0` 허용.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인** — detail 단일 변경, price `0`, cover-only `{}`, 최초·원복 disabled와 화면에 없는 boolean 미전송 test를 작성하고 현재 전체 payload 때문에 실패하는지 확인한다.
- [x] **GREEN: 최소 구현/통과 확인** — 기존 `toUpdateRequest`가 실제 수정 화면 field만 비교해 반환하고 edit button이 같은 결과로 disabled되도록 수정한다.
- [x] **REFACTOR: 정리/회귀 확인** — create path와 비활성화 schema를 건드리지 않고 update serializer의 중복만 정리한 뒤 Audio 전체 회귀를 실행한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts`; `npm run test:run -- src/features/audio-contents`; `npm run typecheck`; `npm run lint`.
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, detail 단일 변경 key 1개, cover-only request `{}`, 무변경 `PUT` 0건.
- **수동 확인:** 1280px에서 detail만 수정하고 multipart `request``detail`만 있는지, 기존 cover 미선택 시 `coverImage`가 없는지 확인한다.
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
#### Task 1.3 커뮤니티 게시글 변경 field 직렬화
**Goal 실행 `P1-T3`:** 커뮤니티 게시글 수정 저장이 변경된 content·comment permission·adult flag와 새 image만 포함한다.
- **시작 조건:** `P1-T2` 완료, `COMM-001`, `DIFF-001~005`, API Contract §1·§2.3 확정.
- **완료 증거:** 단일 field·boolean false·image-only·무변경 test, 고정·soft delete 회귀, Progress 기록.
- **범위 밖:** create form, audio file, `isFixed` 전환, soft delete 계약.
**Files:**
- Modify: `src/features/community-posts/components/CommunityPostSheet.tsx`
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
- Test: `src/features/community-posts/tests/community-contract.test.ts`
**Interfaces:**
- Consumes: `CommunityPostListItem`, `CommunityPostUpdateRequest`, `updateCommunityPost`.
- Produces: Sheet의 현재 수정 field와 `post` 기준값을 비교한 request, 같은 request와 `postImage` 기반 `hasChanges`.
- 보존: `toggleFixed``{isFixed}`, `softDeleteCommunityPost`의 기존 request, file part `postImage`.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인** — content 단일 변경, boolean true→false, image-only `{}`, 최초·원복 disabled test를 작성하고 현재 `isFixed`와 미변경 field가 함께 전송되는 실패를 확인한다.
- [x] **GREEN: 최소 구현/통과 확인** — Sheet의 일반 수정 저장 request만 field별 비교하고 저장 button이 같은 `hasChanges`를 사용하도록 수정한다.
- [x] **REFACTOR: 정리/회귀 확인** — 고정·비활성화 action의 전용 함수와 pending guard를 유지하고 일반 수정 계산만 읽기 쉽게 정리한 뒤 Community 전체 회귀를 실행한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts`; `npm run test:run -- src/features/community-posts`; `npm run typecheck`; `npm run lint`.
- **기대 결과:** 모든 명령 `exit 0`, 신규 4개 변경 감지 test를 포함한 실행 test 전부 통과, content 단일 변경 key 1개, 일반 수정 `isFixed` 0건, 무변경 `PUT` 0건.
- **수동 확인:** 지원 viewport에서 Sheet를 열어 content만 수정했을 때 request에 content만 있고 고정 button과 비활성화 button 동작이 유지되는지 확인한다.
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
#### Task 1.4 시리즈 변경 field 직렬화
**Goal 실행 `P1-T4`:** 시리즈 수정 request가 변경된 기본·enum·nullable field와 새 image만 포함한다.
- **시작 조건:** `P1-T3` 완료, `SERIES-001`, `DIFF-001~005`, API Contract §1·§2.4 확정.
- **완료 증거:** title 단일 변경, nullable 삭제, 배열·enum 변경, image-only·무변경 test, 비활성화 회귀, Progress 기록.
- **범위 밖:** create-only keyword, 연결 콘텐츠, 순서 변경, 비활성화 payload.
**Files:**
- Modify: `src/features/series/components/SeriesForm.tsx`
- Test: `src/features/series/tests/series-form.test.tsx`
- Test: `src/features/series/tests/series-contract.test.ts`
- Test: `src/features/series/tests/series-update-invariants.test.ts`
**Interfaces:**
- Consumes: `SeriesListItem`, `SeriesUpdateRequest`, 기존 `textOrNull`, `editedState`, `updateSeries`.
- Produces: 모든 edit field에 `editedState`와 같은 원본 비교를 적용한 update request, 같은 request와 `image` 기반 `hasChanges`.
- 보존: array field 변경 시 전체 새 배열, writer·studio 삭제 시 `null`, 일반 update의 `isActive` 금지.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인** — title 단일 변경, writer 삭제 `null`, published days 변경, image-only `{}`, 최초·원복 disabled test를 작성하고 state 외 미변경 field가 전송되는 실패를 확인한다.
- [x] **GREEN: 최소 구현/통과 확인** — 기존 `editedState` 패턴을 edit field 전체에 적용해 request를 만들고 form disabled·dirty 판정이 같은 결과를 사용하게 한다.
- [x] **REFACTOR: 정리/회귀 확인** — field 의미를 감추는 범용 abstraction 없이 local serializer 하나로 중복만 줄이고 Series 전체 회귀를 실행한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/series/tests/series-form.test.tsx src/features/series/tests/series-contract.test.ts src/features/series/tests/series-update-invariants.test.ts`; `npm run test:run -- src/features/series`; `npm run typecheck`; `npm run lint`.
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, title 단일 변경 key 1개, nullable 삭제 `null` 보존, 무변경 `PUT` 0건.
- **수동 확인:** 1280px에서 title만 수정, writer 삭제, image-only 교체를 각각 실행해 request key와 file part를 확인한다.
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
#### Task 1.5 FanTalk 답글 무변경 수정 차단
**Goal 실행 `P1-T5`:** FanTalk 답글은 기존 답글과 다른 content가 있을 때만 수정 request를 전송한다.
- **시작 조건:** `P1-T4` 완료, `FANTALK-001`, `DIFF-001`, `DIFF-004`, API Contract §1.4·§2.5 확정.
- **완료 증거:** 최초 disabled, 변경 enabled·`{content}`, 원복 disabled·`PUT` 0건 test, FanTalk 회귀, Progress 기록.
- **범위 밖:** 답글 생성, FanTalk 원글 삭제, `isActive`, 답글 trim 정책 변경.
**Files:**
- Modify: `src/features/fan-talks/components/FanTalkReplySheet.tsx`
- Modify: `src/features/fan-talks/components/FanTalkReplyForm.tsx`
- Test: `src/features/fan-talks/tests/fan-talk-reply.test.tsx`
- Test: `src/features/fan-talks/tests/fan-talk-contract.test.ts`
**Interfaces:**
- Consumes: `FanTalkCreatorReply.content`, `FanTalkReplyForm`, `updateFanTalkReply`.
- Produces: edit mode에서 `content !== existingReply.content`를 나타내는 submit disabled prop, 변경 시 기존 `{content}` request.
- 보존: create mode의 빈값 validation, pending single-flight, update response와 원글 DELETE.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인** — 기존 답글 open 직후 수정 button disabled, content 변경 후 enabled·PUT 1건, 원복 후 disabled·PUT 0건 test를 작성하고 현재 항상 enabled인 실패를 확인한다.
- [x] **GREEN: 최소 구현/통과 확인** — Sheet가 edit 변경 여부를 계산해 Form submit button에 전달하고 기존 update body는 유지한다.
- [x] **REFACTOR: 정리/회귀 확인** — create와 edit의 disabled 이유를 명확히 유지하고 새 serializer나 API helper 없이 FanTalk 전체 회귀를 실행한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/fan-talks/tests/fan-talk-reply.test.tsx src/features/fan-talks/tests/fan-talk-contract.test.ts`; `npm run test:run -- src/features/fan-talks`; `npm run typecheck`; `npm run lint`.
- **기대 결과:** 모든 명령 `exit 0`, 신규 3개 변경 감지 test를 포함한 실행 test 전부 통과, 변경 update body `{content}` key 1개, 무변경·원복 `PUT` 0건.
- **수동 확인:** FanTalk 답글 Sheet를 열면 수정 button이 disabled이고, 내용 변경 시 enabled, 원복 시 다시 disabled인지 확인한다.
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
#### Task R1.1 커뮤니티 content 직렬화 비교 일치
**Goal 실행 `P1-R1`:** `REV-P1-001`을 수정해 커뮤니티 게시글의 기존 원문 직렬화와 변경 판정을 일치시키고 공백 변경 누락을 방지한다.
- **시작 조건:** `REV-P1-001` 확정, `P1-T3`·`P1-GATE` 완료, `DIFF-001~004`, API Contract §1.2·§2.3 확정.
- **완료 증거:** 공백 변경 실패 재현 test, 최소 수정 후 focused·Community 전체·Phase Gate 통과, Progress와 review 수정 검증 기록.
- **범위 밖:** 커뮤니티 생성 payload, content trim 정책 신설, 고정 전환, soft delete, 다른 기능 serializer 변경.
**Files:**
- Modify: `src/features/community-posts/components/CommunityPostSheet.tsx`
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
- Modify: `docs/20260806_수정요청변경필드만전송/plan-task.md`
- Add: `docs/20260806_수정요청변경필드만전송/reviews/phase1-changed-field-requests.md`
**Interfaces:**
- Consumes: 기존 Community update의 raw `content`, `CommunityPostListItem.content`, `CommunityPostUpdateRequest`.
- Produces: 현재 `content`와 기준 `post.content`에 동일한 원문 직렬화 규칙을 적용한 변경 판정.
- 보존: 전송하는 `content` 원문, file-only `{}`, `isFixed`·soft delete 전용 request.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인** — 기존 content 앞뒤에 공백을 추가하면 저장이 활성화되고 원문 `{content}`가 전송돼야 하는 test가 현재 trim 비교로 실패하는지 확인한다.
- [x] **GREEN: 최소 구현/통과 확인**`content` 비교의 `trim()`만 제거해 기존 update 직렬화와 일치시키고 focused test를 통과시킨다.
- [x] **REFACTOR: 회귀 확인** — 별도 abstraction 없이 Community 전체와 Phase Gate를 실행한다.
- [x] review 상태와 Progress에 실제 명령·결과를 누적한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts`; `npm run test:run -- src/features/community-posts`; Phase 1 Gate 전체 명령.
- **기대 결과:** 모든 명령 `exit 0`, 공백 변경 request에 raw `content`만 존재, 기존 227개 회귀와 정적 Gate 오류 0건.
- **수동 확인:** 자동 component test로 동일 payload와 disabled 상태를 검증하며 별도 Network 확인은 대체 사유를 review에 기록한다.
#### Task R1.2 PRD 성공 기준 상태 동기화
**Goal 실행 `P1-R2`:** `REV-P1-002`를 수정해 검증 완료된 PRD 성공 기준과 Phase 1 완료 기록을 일치시킨다.
- **시작 조건:** `REV-P1-002` 확정, `P1-R1`과 Phase Gate 완료.
- **완료 증거:** PRD §14의 검증 완료 항목 체크, plan·review 기록, 문서 정적 검사.
- **범위 밖:** 요구사항·API Contract 의미 변경, 검증하지 않은 항목 완료 처리, 코드 변경.
**Files:**
- Modify: `docs/20260806_수정요청변경필드만전송/prd.md`
- Modify: `docs/20260806_수정요청변경필드만전송/plan-task.md`
- Modify: `docs/20260806_수정요청변경필드만전송/reviews/phase1-changed-field-requests.md`
**Interfaces:**
- Consumes: `P1-T1`~`P1-R1` Progress와 Phase Gate 실제 검증 결과.
- Produces: PRD §14 기능·UI/UX·추적성 성공 기준의 현재 완료 상태.
- 보존: PRD 요구사항 본문, API Contract, §18 요구사항 변경 체크리스트.
**검증 절차:**
- [x] 기존 Phase Gate 증거와 PRD §14 각 항목을 대조한다.
- [x] 증거가 있는 §14 성공 기준만 완료 표시한다.
- [x] `rg``git diff --check`로 미완료·공백 오류를 확인하고 Progress·review에 결과를 기록한다.
**TDD 예외 사유:** 실행 코드가 아닌 완료 상태 문서 동기화이며, 근거는 이미 통과한 component·E2E·정적 Gate다.
### 완료 조건
- [x] `P1-T1`~`P1-T5`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] `DIFF-001~005`, 기능별 요구사항과 파일·데이터 요구사항이 구현 또는 명시적 제외로 추적된다.
- [x] 다섯 기능의 payload와 저장 disabled가 같은 변경 판정을 사용한다.
- [x] 기존 생성·비활성화·고정·순서·삭제 contract 회귀가 없다.
- [x] 문서, 구현, test와 실제 검증 기록의 차이가 없다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 다섯 수정 기능의 변경 field payload, 무변경 차단과 기존 mutation 회귀를 통합 판정한다.
- **시작 조건:** `P1-T1`~`P1-T5` 완료.
- **완료 증거:** focused·도메인 전체·mock Chromium E2E·정적 Gate와 수동 Network 검증 통과, Progress 기록.
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 refactor.
**실행 명령:**
```bash
npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks
npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium
npm run typecheck
npm run lint
npm run build
git diff --check
```
**기대 결과:** 모든 명령 `exit 0`; 신규 변경 감지 test 최소 22개를 포함한 실행 test 전부 통과; 단일 field request key 1개; 무변경 mutation 0건; 기존 create·deactivate·fixed·order·delete E2E 실패 0건; type·lint·build 오류 0건.
**Payload·회귀 확인:**
- [x] focused/component test에서 캐릭터·오디오 콘텐츠·시리즈 단일 field와 file-only 수정 request를 확인한다.
- [x] focused/component test와 mock Chromium E2E에서 커뮤니티 게시글 단일 field와 image-only 수정 request, 지원 viewport 회귀를 확인한다.
- [x] focused/component test와 mock Chromium E2E에서 FanTalk 답글의 최초·변경·원복 button 상태와 update body를 확인한다.
- [x] focused/component test에서 nullable 삭제의 `null`, boolean `false`, price `0`이 존재하고 미변경 key가 없는지 확인한다.
- [x] focused/component test와 mock Chromium E2E에서 기존 비활성화·고정·삭제 action payload/API 회귀가 없는지 확인한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | PRD·API Contract 확정 | 아니요 | Character 기준값·nullable 계약 보정 |
| 2 | `P1-T2` | `P1-T1` | 아니요 | Audio 상세 DTO와 화면 field 대조 |
| 3 | `P1-T3` | `P1-T2` | 아니요 | 일반 수정과 전용 action request 분리 확인 |
| 4 | `P1-T4` | `P1-T3` | 아니요 | Series nullable·array·enum 계약 대조 |
| 5 | `P1-T5` | `P1-T4` | 아니요 | create/edit mode disabled 조건 분리 확인 |
| 6 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
| 7 | `P1-R1` | `REV-P1-001`, `P1-GATE` | 아니요 | Community 원문 직렬화 계약 재확인 |
| 8 | `P1-R2` | `REV-P1-002`, `P1-R1` | 아니요 | PRD 성공 기준과 Gate 증거 재대조 |
```text
P1-T1 → P1-T2 → P1-T3 → P1-T4 → P1-T5 → P1-GATE → P1-R1 → P1-R2
```
## 변경 금지 항목
- 확정된 PRD·API Contract의 endpoint, method, field type과 nullable 의미를 근거 없이 바꾸지 않는다.
- 기존 완료 체크박스와 Progress·Decision Log·검증 기록을 삭제하거나 덮어쓰지 않는다.
- 생성, 비활성화, 고정, 순서, 삭제 payload를 이번 최적화에 합치지 않는다.
- 수정 화면에 없는 server field를 기준값 보존 명목으로 request에 복사하지 않는다.
- 범용 deep-equality dependency, form framework 또는 전역 diff store를 추가하지 않는다.
- test를 삭제·skip·완화하거나 type assertion으로 오류를 우회하지 않는다.
- token, 파일 본문, signed URL과 관리자 입력 전문을 log·fixture·문서에 기록하지 않는다.
## 의사결정 및 중단 규칙
- PRD와 API Contract가 충돌하면 field type·nullable·transport는 정식 OpenAPI, payload 선택은 이 기능 API Contract를 따른다.
- 현재 form이 기존 상세 DTO에 없는 수정 field를 노출하면 추정 비교하지 않고 해당 field의 근거를 문서화한 뒤 진행한다.
- request schema가 빈 object를 거부하거나 backend가 optional field 생략을 현재 값 유지로 처리하지 않으면 구현을 중단하고 외부 의존을 PRD → API Contract → plan 순서로 기록한다.
- 범위가 바뀌면 `plan-task.md` 체크박스와 Files·Interfaces를 먼저 갱신한 뒤 코드를 수정한다.
- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
- 코드와 일부 test만 끝난 상태에서는 goal을 완료하지 않는다. TDD·검증·Progress 증거까지 충족한 뒤 `complete`로 갱신한다.
- 완료된 Task의 후속 결함은 기존 Task를 다시 열지 않고 `P1-R1`부터 회귀 수정 goal을 추가한다.
## Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
### 문서 준비 — 2026-08-06
- 상태: 완료
- 무엇을: PRD, API Contract, 5개 구현 Task와 Phase Gate를 작성했다.
- 왜: 다섯 수정 기능의 변경 field 전송과 무변경 A안의 실행 기준을 고정하기 위해서다.
- TDD 예외 사유: 이 단계는 실행 코드를 변경하지 않는 요구사항·계약·계획 문서 작성이다.
- 어떻게:
- 코드 조사: 다섯 form·API·schema·test와 정식 OpenAPI의 update schema를 대조했다.
- 인터뷰: 사용자가 무변경 상태의 저장 button disabled·request 0건인 A안을 확정했다.
- 세 문서의 미정 표현·placeholder 정적 검사 — 출력 없음, `exit 0`.
- 세 문서와 정식 OpenAPI 대상 `test -f` — 누락 없음, `exit 0`.
- PRD 필수 18개 section과 plan 필수 12개 section `rg` 검사 — 모두 확인, `exit 0`.
- `git diff --check -- docs/20260806_수정요청변경필드만전송` — 출력 없음, `exit 0`.
- 남은 항목: `P1-T1`~`P1-T5`, `P1-GATE` 구현·검증.
- 다음 행동: `P1-T1`의 Character RED test 작성.
### 구현 Goal 기록 형식
각 Goal 실행 후 아래 항목을 복제하고 실제 값으로 채운다.
- 상태: 진행 중 / 완료 / 차단 감사 중 / 차단
- 무엇을: 완료한 체크박스와 산출물
- 왜: Task objective와 요구사항 ID
- TDD:
- RED: 실패 test 명령, exit code와 의도한 assertion
- GREEN: 같은 focused 명령, exit code와 통과 test 수
- REFACTOR: focused·도메인 회귀 명령, exit code와 통과 test 수
- 어떻게: typecheck·lint·build·수동 검증의 성공·실패·불가 사유
- 남은 항목: 미완료 체크박스 또는 없음
- 다음 행동: 같은 goal의 가장 작은 미완료 단계 또는 다음 Goal ID
### P1-T1 Character 변경 field 직렬화 — 2026-08-06
- 상태: 완료
- 무엇을: 캐릭터 수정의 direct field, optional field, nullable 삭제, image-only `{}`, 무변경·원복 disabled를 변경 field 전송 계약으로 바꿨다.
- 왜: `CHAR-001`, `DIFF-001~005`, `FILE-001`, `DATA-001~002`를 충족하기 위해서다.
- TDD:
- RED: `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` — exit 1. 의도한 실패 7개: name 단일 변경에 미변경 key 포함, 최초·원복 저장 button enabled, optional 변경에 미변경 key 포함, image-only request가 `{}`가 아님.
- GREEN: 같은 focused 명령 — exit 0, 2 files / 21 tests 통과.
- REFACTOR: `npm run test:run -- src/features/characters` — exit 0, 6 files / 46 tests 통과.
- 어떻게:
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
- API multipart helper와 create serializer는 변경하지 않았다.
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
- 남은 항목: `P1-T2`~`P1-T5`, `P1-GATE`.
- 다음 행동: `P1-T2`의 Audio RED test 작성.
### P1-T1 Review Blocker 수정 — 2026-08-06
- 상태: 완료
- 무엇을: direct field 기준값에도 동일한 `trim()` 직렬화 규칙을 적용했다.
- 왜: 리뷰에서 조회값이 공백을 포함하면 최초 진입부터 변경으로 판정되는 `DIFF-002`, `DIFF-004` 위반 가능성이 확인됐다.
- TDD:
- RED: `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` — exit 1. `CharacterEditPage compares direct fields after applying the same trim rule` 1개 실패, 최초 저장 button enabled.
- GREEN: 같은 focused 명령 — exit 0, 2 files / 22 tests 통과.
- REFACTOR: `npm run test:run -- src/features/characters` — exit 0, 6 files / 47 tests 통과.
- 어떻게:
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
- direct field 변경 판정과 submit request 모두 같은 trim 기준값을 사용한다.
- 남은 항목: `P1-T2`~`P1-T5`, `P1-GATE`.
- 다음 행동: `P1-T1` 리뷰 재확인 후 `P1-T2`의 Audio RED test 작성.
### P1-T2 Audio 변경 field 직렬화 — 2026-08-06
- 상태: 완료
- 무엇을: 오디오 콘텐츠 수정의 `title`, `detail`, `tags`, `price` 변경 field와 cover-only `{}` 전송, 무변경·cover 취소 disabled를 구현했다.
- 왜: `AUDIO-001`, `DIFF-001~005`, `FILE-001`, `DATA-001`를 충족하기 위해서다.
- TDD:
- RED: `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts` — exit 1. 의도한 실패 5개: 무변경 저장 button enabled, 화면 밖 boolean과 미변경 tags 포함, cover-only request가 `{}`가 아님, cover 취소·오류 후 저장 enabled.
- GREEN: 같은 focused 명령 — exit 0, 2 files / 24 tests 통과.
- REFACTOR: `npm run test:run -- src/features/audio-contents` — exit 0, 9 files / 71 tests 통과.
- 어떻게:
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
- create upload path와 deactivate `{isActive:false}` 계약은 변경하지 않았다.
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
- 남은 항목: `P1-T3`~`P1-T5`, `P1-GATE`.
- 다음 행동: `P1-T2` 리뷰 확인 후 `P1-T3`의 Community RED test 작성.
### P1-T2 Review Blocker 수정 — 2026-08-06
- 상태: 완료
- 무엇을: edit 저장 가능 여부를 가격 문자열 비교가 아니라 정규화된 `toUpdateRequest` 결과와 cover 변경에서 계산하도록 보정했다.
- 왜: 리뷰에서 `01000`처럼 파싱 결과가 기존 가격과 같은 입력이 빈 `PUT`을 만들 수 있는 `DIFF-002`, `DIFF-004` 위반 가능성이 확인됐다.
- TDD:
- RED: `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts` — exit 1. `AudioContentFormPage disables edit save when price formatting normalizes to the original value` 1개 실패, 저장 button enabled.
- GREEN: 같은 focused 명령 — exit 0, 2 files / 25 tests 통과.
- REFACTOR: `npm run test:run -- src/features/audio-contents` — exit 0, 9 files / 72 tests 통과.
- 어떻게:
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
- invalid price는 기존처럼 저장 button을 눌러 validation 오류를 표시할 수 있게 유지했다.
- 남은 항목: `P1-T3`~`P1-T5`, `P1-GATE`.
- 다음 행동: `P1-T2` 리뷰 재확인 후 `P1-T3`의 Community RED test 작성.
### P1-T3 Community 변경 field 직렬화 — 2026-08-06
- 상태: 완료
- 무엇을: 커뮤니티 게시글 일반 수정의 `content`, `isAdult`, `isCommentAvailable` 변경 field와 image-only `{}` 전송, 무변경 저장 차단을 구현했다.
- 왜: `COMM-001`, `DIFF-001~005`, `FILE-001`, `DATA-001`를 충족하기 위해서다.
- TDD:
- RED: `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1. 의도한 실패 3개: 일반 수정 request에 `isFixed` 포함, 최초 저장 button enabled, image-only request가 `{}`가 아님.
- GREEN: 같은 focused 명령 — exit 0, 2 files / 26 tests 통과.
- REFACTOR: `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 51 tests 통과.
- 어떻게:
- `CommunityPostSheet`의 일반 저장 request와 저장 disabled가 같은 변경 판정을 사용한다.
- 고정 전환과 soft delete 전용 request는 기존 계약대로 분리해 유지했다.
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
- 남은 항목: `P1-T4`, `P1-T5`, `P1-GATE`.
- 다음 행동: `P1-T4`의 Series RED test 작성.
### P1-T4 Series 변경 field 직렬화 — 2026-08-06
- 상태: 완료
- 무엇을: 시리즈 수정의 기본 field, nullable writer/studio, enum·요일 변경 field와 image-only `{}` 전송, 무변경 저장 차단을 구현했다.
- 왜: `SERIES-001`, `DIFF-001~005`, `FILE-001`, `DATA-001~002`를 충족하기 위해서다.
- TDD:
- RED: `npm run test:run -- src/features/series/tests/series-form.test.tsx src/features/series/tests/series-contract.test.ts src/features/series/tests/series-update-invariants.test.ts` — exit 1. 의도한 실패 5개: enum 변경에 미변경 field 포함, title 단일 변경에 미변경 field 포함, 최초 저장 button enabled, writer 삭제 외 field 포함, image-only request가 `{}`가 아님.
- GREEN: 같은 focused 명령 — exit 0, 3 files / 21 tests 통과.
- REFACTOR: `npm run test:run -- src/features/series` — exit 0, 9 files / 42 tests 통과.
- 어떻게:
- `SeriesForm`의 edit request와 저장 disabled·dirty 판정이 같은 변경 결과를 사용한다.
- create path, deactivate `{isActive:false}`, order/content APIs는 변경하지 않았다.
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
- 남은 항목: `P1-T5`, `P1-GATE`.
- 다음 행동: `P1-T5`의 FanTalk RED test 작성.
### P1-T5 FanTalk 답글 무변경 수정 차단 — 2026-08-06
- 상태: 완료
- 무엇을: FanTalk 답글 수정에서 기존 content와 동일하거나 원복된 상태의 저장 button을 disabled 처리하고, 변경된 content만 `PUT`으로 전송하게 했다.
- 왜: `FANTALK-001`, `DIFF-001`, `DIFF-004`를 충족하기 위해서다.
- TDD:
- RED: `npm run test:run -- src/features/fan-talks/tests/fan-talk-reply.test.tsx src/features/fan-talks/tests/fan-talk-contract.test.ts` — exit 1. 의도한 실패 1개: 기존 답글 open 직후 `답변 수정` button enabled.
- GREEN: 같은 focused 명령 — exit 0, 2 files / 11 tests 통과.
- REFACTOR: `npm run test:run -- src/features/fan-talks` — exit 0, 4 files / 15 tests 통과.
- 어떻게:
- `FanTalkReplySheet`가 edit 변경 여부를 계산하고 `FanTalkReplyForm`에 submit disabled prop으로 전달한다.
- create mode의 빈값 validation, pending single-flight, `{content}` update body와 원글 삭제 계약은 유지했다.
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
- 남은 항목: `P1-GATE`.
- 다음 행동: Phase 1 Gate 검증 실행.
### P1-GATE Phase 1 통합 검증 — 2026-08-06
- 상태: 완료
- 무엇을: 다섯 수정 기능의 변경 field payload, 무변경 저장 차단, 기존 mutation 회귀를 통합 검증했다.
- 왜: Phase 1 완료 조건과 `DIFF-001~005`, `FILE-001`, `DATA-001~002` 적용 결과를 확인하기 위해서다.
- TDD:
- RED: `P1-T1`~`P1-T5` 각 Goal에서 focused 실패를 확인했다.
- GREEN: 각 Goal의 focused 명령이 모두 exit 0으로 통과했다.
- REFACTOR: 각 domain 회귀와 Phase Gate 통합 회귀를 실행했다.
- 어떻게:
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 227 tests 통과.
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — exit 0, 39 tests 통과.
- `npm run typecheck` — exit 0.
- `npm run lint` — exit 0.
- `npm run build` — exit 0, production build 완료.
- `git diff --check` — 출력 없음, exit 0.
- 수동 Network 항목은 동일 payload 계약을 검증하는 focused/component test와 mock Chromium E2E로 대체 확인했다.
- 남은 항목: 없음.
- 다음 행동: 최종 변경 요약과 검증 결과 보고.
### P1-R1 Community content 직렬화 비교 일치 — 2026-08-06
- 상태: 완료
- 무엇을: Community update가 전송하는 raw `content`와 변경 판정을 일치시키고 공백 변경 회귀 test를 추가했다.
- 왜: [`REV-P1-001`](./reviews/phase1-changed-field-requests.md)의 `DIFF-001`, `DIFF-002`, `DIFF-004`, `COMM-001` 위반을 수정하기 위해서다.
- TDD:
- RED: `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1, 1 failed / 26 passed. `Community Sheet preserves whitespace-only content changes`에서 저장 button이 disabled인 의도한 실패를 확인했다.
- GREEN: 같은 focused 명령 — exit 0, 2 files / 27 tests 통과.
- REFACTOR: `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 52 tests 통과. 새 abstraction 없이 비교식 1줄만 수정했다.
- 어떻게:
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 228 tests 통과.
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — sandbox 실행은 `127.0.0.1:8889` bind `EPERM`으로 불가했고, 승인된 동일 명령 재실행은 exit 0, 39 tests 통과.
- `npm run typecheck` — exit 0.
- `npm run lint` — exit 0.
- `npm run build` — exit 0, production build 완료.
- `git diff --check` — 출력 없음, exit 0.
- 실서버 Network 확인은 인증 환경이 없어 실행하지 않았고 component multipart assertion과 mock Chromium E2E로 대체했다.
- 남은 항목: 없음.
- 다음 행동: 최종 리뷰 결과 보고.
### P1-R2 PRD 성공 기준 상태 동기화 — 2026-08-06
- 상태: 완료
- 무엇을: Phase Gate와 회귀 검증 증거에 따라 PRD §14 성공 기준 13개를 완료 상태로 동기화했다.
- 왜: [`REV-P1-002`](./reviews/phase1-changed-field-requests.md)의 PRD·plan 완료 상태 불일치를 수정하기 위해서다.
- TDD 예외 사유: 실행 코드가 아닌 완료 상태 문서 동기화다.
- 어떻게:
- `rg -n "^- \\[x\\]" docs/20260806_수정요청변경필드만전송/prd.md` — §14 완료 항목 13개 확인, exit 0.
- `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md` — §18 요구사항 변경 체크리스트 5개만 유지, exit 0.
- 대상 PRD·API Contract·plan·review `test -f` — 누락 없음, exit 0.
- `git diff --check` — 출력 없음, exit 0.
- 남은 항목: 없음.
- 다음 행동: 최종 리뷰 결과 보고.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-06 | `DEC-001` | 확정 | 기존 `PUT`·multipart·JSON transport를 유지하고 최종 변경 field만 보낸다. | 사용자 요청, OpenAPI update field optional 계약 | `P1-T1`~`P1-T5`, API Contract §1 |
| 2026-08-06 | `DEC-002` | 확정 | 변경 field·file 0개면 저장 button disabled와 mutation 0건으로 처리한다. | 사용자 인터뷰 A안 | `P1-T1`~`P1-T5` |
| 2026-08-06 | `DEC-003` | 확정 | file-only multipart는 required `request` part를 `{}`로 보낸다. | multipart contract와 빈 update object 허용 schema | `P1-T1`~`P1-T4` |
| 2026-08-06 | `DEC-004` | 확정 | payload와 disabled 판정은 같은 domain request 결과를 사용한다. | 원복·trim·nullable 판정 불일치 방지 | `P1-T1`~`P1-T5` |
| 2026-08-06 | `DEC-005` | 확정 | domain-local serializer를 사용하고 새 공통 diff abstraction을 만들지 않는다. | field별 nullable·array·file 의미 차이와 최소 변경 원칙 | `P1-T1`~`P1-T5` |
| 2026-08-06 | `DEC-006` | 확정 | Community update의 `content`는 기존 원문 전송을 유지하고 현재 값과 기준값도 원문으로 비교한다. | `DIFF-002`의 같은 기존 직렬화 적용, 기존 update가 trim 없이 전송한 코드 | `P1-R1`, `REV-P1-001` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| `ISSUE-001` | High | 확정 | Character edit가 direct·optional·repeated 현재값 전체를 request에 넣는다. | `P1-T1` | 기준값 비교 serializer와 no-op test 추가 |
| `ISSUE-002` | High | 확정 | Audio edit가 수정 화면에 없는 boolean을 포함한 전체 update DTO를 보낸다. | `P1-T2` | 화면 field만 비교·전송 |
| `ISSUE-003` | High | 확정 | Community 일반 수정이 미변경 `isFixed`와 나머지 편집값을 함께 보낸다. | `P1-T3` | 일반 수정 request와 전용 action 유지 |
| `ISSUE-004` | High | 확정 | Series는 state만 미변경 생략하고 나머지 edit field는 모두 보낸다. | `P1-T4` | 기존 state 원본 비교를 전체 edit field로 확장 |
| `ISSUE-005` | Medium | 확정 | FanTalk reply는 update field가 content 하나라 변경 payload는 이미 최소지만 무변경 수정 요청을 차단하지 않는다. | `P1-T5` | edit submit disabled와 request 0건 test 추가 |
| `ISSUE-006` | High | 해결 | Community update가 raw `content`를 전송하면서 변경 판정만 trim해 공백 변경을 누락한다. | `P1-R1` | [`REV-P1-001`](./reviews/phase1-changed-field-requests.md) 수정·검증 완료 |
| `ISSUE-007` | Low | 해결 | Phase 1 구현·Gate 완료 후에도 PRD §14 성공 기준이 미완료로 남아 있다. | `P1-R2` | [`REV-P1-002`](./reviews/phase1-changed-field-requests.md) 수정·검증 완료 |
## 최종 보고 형식
```markdown
구현 결과: 다섯 수정 기능의 변경 field 전송과 무변경 저장 차단
- 변경: domain별 serializer·form·test와 실제 payload
- 결정: DEC-001~005 적용 결과
- 검증:
- focused·도메인 회귀·mock Chromium E2E — 성공/실패와 test 수
- typecheck·lint·build·git diff --check — exit code와 핵심 결과
- Network 수동 검증 — 단일 field, nullable, false·0, file-only, 무변경 결과
- 남은 항목: 외부 의존, 후속 회귀 또는 없음
- 문서: PRD, API Contract, plan, review 링크
```
최종 보고는 성공을 추정하지 않고 실제 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.

View File

@@ -0,0 +1,274 @@
# 수정 요청 변경 필드 전송 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-06 |
| 최종 수정일 | 2026-08-06 |
| 대상 제품 | AI 캐릭터 관리자 웹 수정 요청 최적화 |
| 작성자·결정권자 | 작성자: Codex / 결정권자: 사용자 |
| 관련 API Contract | [api-contract.md](./api-contract.md) |
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
| 관련 review | 없음 — 구현 완료 후 `reviews/`에 추가 |
### 요구사항 상태
| 상태 | 의미 | 구현 처리 |
|---|---|---|
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
| 미결 | 제품·UX·운영 결정이 더 필요함 | 권고안과 결정 주체·시점을 기록하고 임의 구현 금지 |
| 외부 의존 | 프론트엔드 밖의 계약·권한·환경 제공이 필요함 | 담당 주체·영향·재개 조건을 기록하고 추정 구현 금지 |
| 권고 | 미결 항목에 대한 현재 추천안 | 확정 전 계약이나 수용 기준으로 사용하지 않음 |
| 제외 | 현재 범위에서 구현하지 않기로 결정 | 제외 이유와 후속 조건을 Decision Log에 기록 |
### 문서 우선순위와 갱신 순서
1. 사용자·제품 결정은 이 PRD에 기록한다.
2. request payload 규칙은 [api-contract.md](./api-contract.md)에 기록한다.
3. 구현 범위·순서·완료 증거는 [plan-task.md](./plan-task.md)에 기록한다.
4. 요구사항 변경 시 Decision Log → 요구사항·수용 기준 → API Contract → 구현 계획 순서로 갱신한다.
5. 기존 결정과 검증 기록은 삭제하거나 덮어쓰지 않고 정정 기록을 누적한다.
## 1. Overview
관리자가 AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글을 수정하면 프론트엔드는 현재 값 전체가 아니라 최종적으로 변경된 필드만 기존 수정 endpoint에 전송한다. 기존 화면, HTTP method, multipart 구조, 인증·응답·오류 계약은 유지하고 request payload 생성과 무변경 저장 동작만 바꾼다.
## 2. Problem Statement
현재 관리자는 다음 문제를 겪는다.
- 한 필드만 수정해도 화면이 보유한 다른 수정 가능 값이 함께 전송된다.
- 사용자가 건드리지 않은 값까지 서버에 다시 기록될 수 있어 동시 변경을 덮어쓸 위험과 payload 확인 비용이 커진다.
- 일부 화면은 이미 특정 필드만 생략하지만, 도메인마다 규칙이 달라 변경 필드 전송 여부를 일관되게 검증하기 어렵다.
문제를 해결했다는 판단은 각 수정 화면에서 한 필드만 바꿨을 때 request JSON에 그 필드만 존재하고, 변경이 없을 때 저장 버튼이 비활성화되며 mutation 요청이 0건인 것으로 한다.
## 3. Goals
### 3.1 제품 목표
- 다섯 수정 기능이 실제 변경 필드와 새로 선택한 파일만 전송한다.
- 값 삭제는 기존 nullable 계약에 맞는 `null`을 전송하고, 미변경은 key 생략으로 구분한다.
- 변경 후 원래 값으로 되돌리면 변경 없음으로 판정해 불필요한 mutation을 만들지 않는다.
### 3.2 UX 목표
- 변경 사항이 없으면 저장 버튼을 비활성화해 요청이 발생하지 않음을 사전에 알린다.
- 유효한 변경이 있으면 기존 저장 중·성공·실패·중복 제출 방지 동작을 유지한다.
- 기존 반응형 capability, keyboard 동작, label·오류 연결과 focus 정책을 회귀시키지 않는다.
## 4. Non-Goals
- 기존 `PUT` endpoint를 `PATCH`로 바꾸지 않는다.
- backend DTO, response, 오류 status/key 또는 저장 로직을 변경하지 않는다.
- 생성, 비활성화, 게시글 고정 전환, 시리즈 순서 변경, FanTalk 원글 삭제 payload는 변경하지 않는다.
- 수정 화면에 없는 필드를 새로 노출하지 않는다.
- 새 dependency, 범용 form library 또는 전역 diff framework를 도입하지 않는다.
Non-Goal 변경 시 Decision Log와 `plan-task.md` 범위를 먼저 갱신한다.
## 5. Target Users and Permissions
### 5.1 사용자
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| 인증된 관리자 | 선택한 리소스의 의도한 값만 안전하게 수정 | 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈·FanTalk 답글 수정 | 기존 기능별 지원 viewport |
### 5.2 권한
- 인증 주체: 기존 관리자 bearer session
- 허용 역할: 기존 각 수정 endpoint의 관리자 권한
- 거부 조건: 기존 401·403 및 공통 인증 만료 정책 유지
- 리소스 소유권: path의 `characterId`와 각 resource ID 기준 서버 검증 유지
- read-only 조건: 비활성 캐릭터와 모바일 mutation 제한 등 기존 기능별 capability 유지
## 6. 핵심 사용자 흐름
1. 관리자가 기존 상세·목록에서 수정 화면 또는 Sheet를 연다.
2. 프론트엔드는 조회 응답을 수정 기준값으로 보존한다.
3. 관리자가 하나 이상의 필드 또는 교체 파일을 변경한다.
4. 프론트엔드는 기존 직렬화 규칙을 적용한 현재 값과 기준값을 필드별로 비교해 변경 필드만 request에 넣는다.
5. 저장 성공·실패와 다음 화면 이동은 기존 기능 동작을 유지한다.
변경 필드가 없으면 저장 버튼은 비활성화되고 mutation 요청은 발생하지 않는다. 파일만 변경한 multipart 수정은 파일 파트와 빈 JSON object인 `request: {}`를 전송한다.
## 7. 정보 구조와 라우팅
```text
/ai-characters/:characterId/edit
/ai-characters/:characterId/audio-contents/:contentId/edit
/ai-characters/:characterId/community-posts # 목록 내 게시글 Sheet
/ai-characters/:characterId/series/:seriesId/edit
/ai-characters/:characterId/fan-talks # 목록 내 답글 Sheet
```
- route, path parameter, query parameter와 성공 후 이동 위치는 변경하지 않는다.
- 커뮤니티 게시글과 FanTalk 답글은 별도 수정 route 없이 기존 Sheet에서 수정한다.
- 직접 링크·새로고침·존재하지 않음·비활성 리소스 처리는 기존 정책을 유지한다.
## 8. 기능 요구사항
### 8.1 공통 변경 감지와 전송
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `DIFF-001` | 확정 | 수정 request JSON은 최종 변경 필드만 포함한다. | 한 필드 변경 시 해당 key만 존재하고 미변경 key는 0개다. | API Contract §2, `P1-T1`~`P1-T5` |
| `DIFF-002` | 확정 | 변경 여부는 각 기능의 기존 trim·빈값→`null`·list 직렬화 규칙을 현재 값과 기준값에 동일하게 적용한 뒤 판정한다. | 공백 정리 후 원래 값과 같거나 변경 후 되돌린 필드는 생략된다. | API Contract §1.2, `P1-T1`~`P1-T5` |
| `DIFF-003` | 확정 | 필드 삭제는 계약상 삭제 의미인 `null`을 보내고 미변경은 key를 생략한다. | nullable 값을 비우면 `{field:null}`, 건드리지 않으면 field key가 없다. | API Contract §1.3, `P1-T1`, `P1-T4` |
| `DIFF-004` | 확정 | 변경 필드와 교체 파일이 모두 없으면 저장 버튼을 비활성화하고 mutation을 호출하지 않는다. | 최초 진입과 변경 후 원복 상태에서 저장 버튼 disabled, `PUT` 0건이다. | API Contract §1.4, `P1-T1`~`P1-T5` |
| `DIFF-005` | 확정 | 파일만 바뀐 multipart 수정은 교체 파일과 빈 `request` JSON object를 보낸다. | 파일 파트 1개, request `{}`, 다른 JSON key 0개다. | API Contract §1.5, `P1-T1`~`P1-T4` |
### 8.2 기능별 수정 payload
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `CHAR-001` | 확정 | 캐릭터 수정은 변경된 프로필·선택·반복 필드와 새 profile image만 전송한다. | `name`만 변경하면 request는 `{name}`이고 `region`, `isActive`와 미변경 필드는 없다. | API Contract §2.1, `P1-T1` |
| `AUDIO-001` | 확정 | 오디오 콘텐츠 수정은 변경된 `title`, `detail`, `tags`, `price`와 새 cover image만 전송한다. | `detail`만 변경하면 request는 `{detail}`이며 화면에 없는 boolean 필드는 없다. | API Contract §2.2, `P1-T2` |
| `COMM-001` | 확정 | 커뮤니티 게시글 수정 저장은 변경된 `content`, `isCommentAvailable`, `isAdult`와 새 post image만 전송한다. | `content`만 변경하면 request는 `{content}`이고 `isFixed`는 없다. | API Contract §2.3, `P1-T3` |
| `SERIES-001` | 확정 | 시리즈 수정은 변경된 기본·enum·nullable 필드와 새 image만 전송한다. | `title`만 변경하면 request는 `{title}`이고 이미 부분 적용된 `state` 포함 다른 미변경 필드는 없다. | API Contract §2.4, `P1-T4` |
| `FANTALK-001` | 확정 | FanTalk 답글 수정은 기존 답글과 다른 `content`만 전송한다. | 변경 시 `{content}` 1개, 미변경 시 수정 버튼 disabled와 `PUT` 0건이다. | API Contract §2.5, `P1-T5` |
### 8.3 공통 파일·데이터 정책
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `FILE-001` | 확정 | 새 파일을 선택하지 않으면 기존 이미지·커버를 유지하고 파일 파트를 생략한다. | 미선택 수정 request에서 관련 파일 part가 없다. | API Contract §1.5, `P1-T1`~`P1-T4` |
| `DATA-001` | 확정 | 숫자, boolean, enum, 배열과 객체 배열은 타입을 유지한 채 비교·전송한다. | `false`, `0`, 빈 값 삭제용 `null`이 누락되지 않고 배열 변경은 전체 해당 필드 값으로 전송된다. | API Contract §1.3, `P1-T1`~`P1-T4` |
| `DATA-002` | 확정 | 원작 미선택·선택 해제 시 `originalWorkId` key를 생략하는 기존 계약을 유지한다. | 기존 `serializeCharacterRequest` 계약 test가 유지되고 원작 연결 해제 동작은 새로 만들지 않는다. | API Contract §2.1, `P1-T1` |
## 9. 반응형 기능 범위
| 기능 | Desktop | Tablet | Mobile | 비고 |
|---|---:|---:|---:|---|
| 캐릭터·오디오 콘텐츠·시리즈 수정 | 허용 | 허용 | 기존 조회 전용 | 기존 직접 route 차단 유지 |
| 커뮤니티 게시글 수정 | 허용 | 허용 | 기존 capability 유지 | 기존 Sheet 정책 유지 |
| FanTalk 답글 수정 | 허용 | 허용 | 허용 | 기존 Sheet 정책 유지 |
- 이번 변경으로 viewport breakpoint나 action 노출 정책을 바꾸지 않는다.
- 기존 최소 viewport, 200% zoom, touch target과 virtual keyboard 검증을 회귀 Gate로 사용한다.
## 10. UI/UX Expectations
### 10.1 디자인과 component 원칙
- 기존 component와 design token을 그대로 사용한다.
- 수정용 payload는 각 도메인의 기존 form serializer 또는 component에서 계산한다.
- 새 dependency나 범용 diff abstraction을 만들지 않고 기능별 DTO 의미를 코드 가까이에 둔다.
### 10.2 화면 상태
- 최초 진입과 모든 변경을 원복한 상태에서는 저장 버튼을 disabled로 표시한다.
- 파일 준비·저장 pending·오류·성공 상태와 중복 제출 방지는 기존 동작을 유지한다.
- payload 생성 결과와 저장 버튼 활성화 조건은 같은 `hasChanges` 판정을 사용한다.
### 10.3 접근성
- disabled 상태는 native `disabled` 속성으로 노출한다.
- 기존 visible label, 연결 오류, keyboard focus 순서와 성공·오류 live region을 유지한다.
- 지원 viewport와 200% zoom에서 핵심 control이 가려지지 않고 axe critical·serious 위반 0건을 유지한다.
## 11. API 계약
### 11.1 공통 규칙
- base URL·인증 header·locale·성공 envelope·오류 envelope는 기존 [정식 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)를 유지한다.
- HTTP method는 기존 `PUT`을 유지한다.
- 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈는 `multipart/form-data``request` JSON part를 유지한다.
- FanTalk 답글은 `application/json`을 유지한다.
- request field의 생략은 미변경, 명시적 `null`은 해당 DTO가 정의한 값 삭제를 의미한다.
### 11.2 Endpoint 추적
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|---|---|---|---|---|---|
| `CHAR-001` | PUT | `/api/v2/admin/ai-characters/{characterId}` | 제공됨 | §2.1 | `P1-T1` |
| `AUDIO-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 제공됨 | §2.2 | `P1-T2` |
| `COMM-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` | 제공됨 | §2.3 | `P1-T3` |
| `SERIES-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` | 제공됨 | §2.4 | `P1-T4` |
| `FANTALK-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | 제공됨 | §2.5 | `P1-T5` |
### 11.3 외부 제공 대기 계약
없음. 정식 OpenAPI에서 대상 update field가 모두 optional이고 현재 프론트엔드 schema도 부분 request를 허용한다.
## 12. 보안과 데이터 취급
- 인증 저장·만료 lifecycle과 401·403 처리는 기존 공통 API client 정책을 유지한다.
- token, 파일 본문, signed URL과 관리자 입력 전문을 새 log·분석 이벤트에 기록하지 않는다.
- 파일 확장자·MIME·크기·crop 검증과 resource ownership 검증을 변경하지 않는다.
- 부분 request를 이유로 client가 권한 또는 서버 validation을 대신하지 않는다.
- 감사 로그 추가는 이번 범위에 포함하지 않는다.
## 13. 성능과 품질 요구사항
- payload 크기는 같거나 작아야 하며 미변경 저장 network 요청은 0건이어야 한다.
- 변경 판정은 현재 form field 수에 대한 동기 비교로 처리하고 새 network 조회나 dependency를 추가하지 않는다.
- mutation single-flight, 파일 준비 취소·오류·재시도와 기존 browser 지원 범위를 유지한다.
- test stack은 Vitest·Testing Library·Playwright mock E2E, TypeScript typecheck, ESLint, Vite production build를 사용한다.
- backend 구현 전 mock fallback은 필요하지 않다. 기존 server/mock mode 경계를 유지하고 production 자동 mock fallback을 추가하지 않는다.
## 14. 성공 기준
### 14.1 기능 수용 기준
- [x] 다섯 수정 기능에서 한 필드 변경 request는 해당 key만 포함한다. (`DIFF-001`, `P1-GATE`)
- [x] nullable 필드 삭제와 미변경 생략이 구분된다. (`DIFF-003`, `P1-T1`, `P1-T4`)
- [x] 파일 미변경은 파일 part 생략, 파일만 변경은 파일 part와 `request: {}`를 전송한다. (`DIFF-005`, `FILE-001`)
- [x] 최초 진입과 변경 후 원복 상태에서 저장 버튼이 disabled이고 mutation 요청이 없다. (`DIFF-004`)
- [x] 기존 생성·비활성화·고정·순서·삭제 흐름이 회귀하지 않는다. (`P1-GATE`)
### 14.2 UI/UX 수용 기준
- [x] 기존 loading·error·success·pending 상태가 유지된다.
- [x] keyboard-only로 기존 수정 흐름을 완료할 수 있다.
- [x] 기존 지원 viewport와 200% zoom에서 핵심 control이 가려지지 않는다.
- [x] axe critical·serious 위반이 0건이다.
### 14.3 추적성 완료 기준
- [x] 모든 확정 요구사항이 API Contract와 하나 이상의 Task·Goal 완료 증거로 연결된다.
- [x] 각 구현 Task에 RED·GREEN·REFACTOR 결과가 Progress에 누적된다.
- [x] Phase Gate의 자동·수동 payload 검증 결과가 기록된다.
- [x] 미결·외부 의존·제외 상태의 새 항목이 생기면 담당·영향·재개 조건 또는 Decision Log가 추가된다.
## 15. Open Questions
열린 질문 없음.
인터뷰 결과:
- 최종 모호성: `0.07`
- 명확성: Goal `1.00`, Scope `0.85`, Constraints `0.90`, Success `0.90`, Context `1.00`
- 확정 결정: 변경 필드만 전송하며, 변경이 없으면 저장 버튼 비활성화와 mutation 요청 0건
## 16. 요구사항 추적표
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---|---:|---|---|---|
| `DIFF-001~005`, `FILE-001`, `DATA-001` | §1 | 1 | `P1-T1`~`P1-T5`, `P1-GATE` | 기능별 form·contract test | DevTools Network payload·무요청 확인 |
| `CHAR-001`, `DATA-002` | §2.1 | 1 | `P1-T1` | Character edit/API test | 캐릭터 단일 필드·파일 수정 |
| `AUDIO-001` | §2.2 | 1 | `P1-T2` | Audio form/API test | 오디오 단일 필드·cover 수정 |
| `COMM-001` | §2.3 | 1 | `P1-T3` | Community Sheet/API test | 게시글 단일 필드·image 수정 |
| `SERIES-001` | §2.4 | 1 | `P1-T4` | Series form/API test | 시리즈 단일 필드·image 수정 |
| `FANTALK-001` | §2.5 | 1 | `P1-T5` | FanTalk reply/API test | 답글 변경·무변경 수정 |
## 17. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| 2026-08-06 | `DEC-001` | 확정 | 수정 request는 HTTP method를 바꾸지 않고 최종 변경 필드만 포함한다. | 사용자 요청과 정식 OpenAPI의 optional update field | `DIFF-001~003`, API Contract §1, `P1-T1`~`P1-T5` |
| 2026-08-06 | `DEC-002` | 확정 | 변경 필드와 교체 파일이 없으면 저장 버튼을 비활성화하고 요청하지 않는다. | 사용자 인터뷰 A안 선택 | `DIFF-004`, `P1-T1`~`P1-T5` |
| 2026-08-06 | `DEC-003` | 확정 | 파일만 변경한 multipart update는 required `request` part를 빈 object로 전송한다. | 기존 multipart 계약에서 `request` part가 required이고 update object에는 required field가 없음 | `DIFF-005`, API Contract §1.5, `P1-T1`~`P1-T4` |
| 2026-08-06 | `DEC-004` | 확정 | 생성·비활성화·고정·순서·삭제 전용 mutation은 범위에서 제외한다. | 해당 action은 이미 전용 최소 payload 또는 별도 method를 사용함 | Non-Goals, `P1-GATE` |
| 2026-08-06 | `DEC-005` | 확정 | 새 공통 diff abstraction이나 dependency 없이 각 도메인의 기존 serializer와 원본 DTO 비교를 사용한다. | DTO별 `null`, 배열, 파일과 수정 가능 필드 의미가 다름 | §10.1, `P1-T1`~`P1-T5` |
## 18. 변경 관리
- [x] Decision Log에 변경 이유와 날짜를 기록한다.
- [x] 관련 요구사항 상태·본문·수용 기준을 갱신한다.
- [x] API Contract의 request 규칙과 예시를 갱신한다.
- [x] `plan-task.md`의 범위·Files·Interfaces·체크박스·완료 증거를 코드 변경 전에 갱신한다.
- [x] 기존 Progress·review·검증 기록을 삭제하거나 덮어쓰지 않는다.

View File

@@ -0,0 +1,239 @@
# Phase 1 변경 필드 요청 코드 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / `P1-T1`~`P1-T5`, `P1-GATE` |
| 기준 commit 또는 working tree | 미커밋 working tree (`git status --short` 기준 기능 코드 14개 수정, 기능 문서 디렉터리 신규) |
| 리뷰 일자 | 2026-08-06 |
| 리뷰어 | Codex |
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
| 리뷰 상태 | 수정 검증 완료 |
## 2. 리뷰 목적과 범위
### 목적
- 다섯 수정 기능이 최종 변경 field와 교체 file만 전송하는지 확인한다.
- 무변경 차단, 기존 직렬화, 전용 mutation 제외와 계획의 완료 기록이 실제 코드·test와 일치하는지 확인한다.
### 포함 범위
- 코드: `src/features/characters`, `src/features/audio-contents`, `src/features/community-posts`, `src/features/series`, `src/features/fan-talks`의 수정 form·serializer·API 경계
- 테스트: 위 다섯 feature의 Vitest 전체와 관련 mock Chromium E2E 계획
- 문서: `DIFF-001~005`, `CHAR-001`, `AUDIO-001`, `COMM-001`, `SERIES-001`, `FANTALK-001`, `FILE-001`, `DATA-001~002`, Phase 1 Task·Gate
- 수동 검증: payload와 disabled 상태는 component test와 mock E2E로 대체하며 별도 실서버 Network 검증은 수행하지 않는다.
### 제외 범위
- 생성, 비활성화, 커뮤니티 고정, 시리즈 순서 변경, FanTalk 원글 삭제의 신규 동작
- backend DTO·저장 로직, 새 field·file 삭제·원작 연결 해제
- 관련 없는 화면·성능·스타일 리팩터링
## 3. 판정 기준
### 심각도
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
### 상태
| 상태 | 의미 | 후속 처리 |
|---|---|---|
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 후보 |
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `DIFF-001~005`, 기능별 `CHAR-001`~`FANTALK-001`, `FILE-001`, `DATA-001~002`
- API Contract: §1 변경 판정·생략·file, §2.1~§2.5 기능별 payload, §4 회귀 보호
- 계획: `P1-T1`~`P1-T5`, `P1-GATE`
- 코드: 다섯 feature의 form·serializer·API helper와 `CommunityPostSheet.tsx:32-42`
- 테스트: 다섯 feature test 디렉터리 전체, `community-sheet.test.tsx`
### 실행 환경
```text
OS: Darwin 25.0.0 x86_64
Node: v24.12.0
npm: 11.7.0
Browser/viewport: Playwright mock Chromium, 대상 spec의 desktop·tablet·mobile viewport
환경 변수: Vitest 기본 test mode, 민감정보 기록 없음
```
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` | 성공 | exit 0, 35 files / 227 tests 통과. 아래 경계 test가 없어 결함을 검출하지 못함 |
| 다섯 기능 form·serializer·test 정적 대조 | 실패 | Community만 outgoing raw `content`와 trim 비교가 불일치 |
| 실서버 DevTools Network | 불가 | test 환경 리뷰이며 인증된 실서버를 사용하지 않음. component multipart assertion으로 대체 예정 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P1-001` | High | 수정 완료 | Community content의 비교와 전송 직렬화가 달라 공백 변경이 누락됨 | `P1-T3``P1-R1` | `P1-R1` 완료 |
| `REV-P1-002` | Low | 수정 완료 | 완료된 Phase 1과 PRD 성공 기준 상태가 불일치함 | `P1-GATE``P1-R2` | `P1-R2` 완료 |
## 6. 발견 사항 상세
### REV-P1-001 — Community content의 비교와 전송 직렬화가 달라 공백 변경이 누락됨
- **심각도:** High
- **상태:** 수정 완료
- **관련 요구사항:** `DIFF-001`, `DIFF-002`, `DIFF-004`, `COMM-001`
- **관련 계약:** API Contract §1.2, §1.4, §2.3
- **소유 Task:** `P1-T3``P1-R1`
**관찰 내용**
Community update는 기존과 같이 form의 `content` 원문을 request에 넣지만, 변경 여부만 현재 값과 기준값을 각각 `trim()`해 비교한다. 따라서 기존 내용 앞뒤에 공백만 추가하거나 제거하면 실제 전송 값은 달라졌어도 request에서 `content`가 생략되고 저장 button이 disabled된다.
**근거**
- 코드: `src/features/community-posts/components/CommunityPostSheet.tsx:33-35`는 trim 비교 후 raw `content`를 할당한다.
- 기존 코드: 구현 전 `savePost``{ content, ... }`로 원문을 전송했으며 update용 schema도 trim 변환을 하지 않는다.
- 테스트: `src/features/community-posts/tests/community-sheet.test.tsx`에는 무변경·일반 content 변경 test는 있지만 공백 경계 test가 없다.
- 문서: PRD `DIFF-002`와 API Contract §1.2는 현재 값과 기준값에 같은 기존 직렬화 규칙을 적용하도록 요구한다.
**재현 또는 검증 절차**
1. `post.content``오늘의 상담 기록입니다.`인 Community Sheet를 연다.
2. textarea 값을 ` 오늘의 상담 기록입니다. `로 바꾼다.
3. 실제 결과: 두 값을 trim해 같다고 판정하므로 `수정 저장`이 disabled이고 request는 0건이다.
4. 요구 결과: 기존 update의 raw content 직렬화를 유지해 저장이 enabled되고 request는 `{ "content": " 오늘의 상담 기록입니다. " }`다.
**영향**
관리자가 게시글 내용의 앞뒤 공백을 의도적으로 변경해도 저장할 수 없으며, payload와 disabled가 동일한 직렬화 결과를 사용한다는 계약을 위반한다.
**권장 조치**
별도 serializer를 만들지 않고 Community의 content 비교에서만 `trim()`을 제거한다. raw content 공백 변경이 저장되고 content 1개만 전송되는 component 회귀 test를 추가한다.
**판정 기록**
- 2026-08-06 — 확정. 기존 update 코드·schema가 raw content를 전송하고 현재 비교만 trim한다는 코드 근거로 판정했다.
- 2026-08-06 — 수정 완료. raw 비교 1줄과 공백 변경 회귀 test를 추가하고 focused·Phase Gate를 통과했다.
### REV-P1-002 — 완료된 Phase 1과 PRD 성공 기준 상태가 불일치함
- **심각도:** Low
- **상태:** 수정 완료
- **관련 요구사항:** PRD §14 성공 기준
- **관련 계약:** API Contract §4 검증 matrix
- **소유 Task:** `P1-GATE``P1-R2`
**관찰 내용**
`plan-task.md``P1-T1`~`P1-GATE`와 자동 검증을 완료로 기록했지만 `prd.md` §14의 기능·UI/UX·추적성 성공 기준은 모두 미완료 체크박스로 남아 있다.
**근거**
- 문서: `plan-task.md`의 완료 조건·Progress는 완료이고 `prd.md:217-235`는 미완료다.
- 테스트: Phase Gate 35 files / 228 tests, mock Chromium 39 tests, typecheck·lint·build가 통과했다.
- 규칙: 문서 유지보수와 리뷰 규칙은 완료 체크박스와 실제 증거의 일치를 요구한다.
**재현 또는 검증 절차**
1. `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md`를 실행한다.
2. 실제 결과: §14 성공 기준 13개가 미완료로 출력된다.
3. `plan-task.md`의 완료 조건과 `P1-GATE`, `P1-R1` Progress를 확인한다.
4. 요구 결과: 실행 증거가 있는 §14 항목은 완료이고, 요구사항 변경 시에만 쓰는 §18 체크리스트는 미완료 상태를 유지한다.
**영향**
구현 완료 여부를 PRD에서 판단할 수 없고 plan·review와 상태가 충돌한다. 실행 동작에는 영향이 없다.
**권장 조치**
새 검증이나 요구사항 변경 없이 기존 Gate 증거와 직접 연결되는 PRD §14 체크박스만 완료 표시한다.
**판정 기록**
- 2026-08-06 — 확정. 같은 working tree의 PRD와 plan 완료 상태가 직접 불일치한다.
- 2026-08-06 — 수정 완료. 기존 Gate 증거와 연결되는 PRD §14 항목 13개를 완료 표시하고 §18 체크리스트는 보존했다.
## 7. 확정 항목의 plan·goal 전환
### 신규 회귀 수정 Task 초안
`plan-task.md``Task R1.1 커뮤니티 content 직렬화 비교 일치`, `Task R1.2 PRD 성공 기준 상태 동기화`와 후속 goal을 반영했다.
- 실패 재현: raw content 공백 변경 시 저장 enabled와 `{content}` 전송 assertion
- 최소 수정: content 비교의 `trim()` 제거
- 검증: Community focused·전체와 Phase 1 Gate
- 범위 밖: content trim 정책 신설과 전용 mutation 변경
`P1-R2`는 기존 Gate 증거와 PRD §14를 대조해 검증된 성공 기준만 완료 표시한다. 요구사항·API Contract 의미와 §18 요구사항 변경 체크리스트는 바꾸지 않는다.
### create_goal objective 초안
```text
[P1-R1]의 확정 review 항목 REV-P1-001을 수정하고 회귀를 방지한다.
plan-task.md에 추가된 회귀 수정 Task만 수행한다.
실패 재현, 최소 수정, focused test, Phase Gate와 검증 기록이 모두 끝나기 전에는 complete로 표시하지 않는다.
관련 없는 리팩터링과 계약 추정은 범위 밖이다.
```
```text
[P1-R2]의 확정 review 항목 REV-P1-002를 수정해 PRD 성공 기준과 Phase 완료 증거를 동기화한다.
검증 증거가 없는 항목과 요구사항 변경 체크리스트는 완료 표시하지 않는다.
코드와 API Contract 의미 변경은 범위 밖이다.
```
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 다섯 feature 코드·test와 세 기준 문서를 대조함 |
| 후보 항목 판정 완료 | 충족 | `REV-P1-001`, `REV-P1-002` 판정·수정 완료 |
| 확정 항목 plan 반영 | 충족 | `P1-R1`, `P1-R2` 추가·완료 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
| 검증 명령과 결과 기록 | 충족 | 35 files / 228 tests, mock Chromium 39 tests와 문서 정적 대조 결과 기록 |
**최종 결론:** 수정 검증 완료
**남은 항목:** 없음
## 9. 수정 후 검증 기록
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
### 1차 수정 검증 — 2026-08-06
- 무엇을: `REV-P1-001`의 Community raw content 비교 불일치를 수정하고 회귀 test를 추가했다.
- 왜: Community content의 비교와 전송 직렬화 불일치
- 어떻게:
- RED `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1, 1 failed / 26 passed. 공백 변경 후 저장 button disabled assertion 실패를 확인했다.
- GREEN 같은 focused 명령 — exit 0, 2 files / 27 tests 통과.
- `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 52 tests 통과.
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 228 tests 통과.
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — sandbox에서는 local port bind `EPERM`; 승인된 동일 명령은 exit 0, 39 tests 통과.
- `npm run typecheck`; `npm run lint`; `npm run build` — 모두 exit 0.
- `git diff --check` — 출력 없음, exit 0.
- 실서버 Network는 인증 환경이 없어 불가했고 component multipart assertion과 mock E2E로 대체했다.
- 남은 항목: 없음
### 2차 수정 검증 — 2026-08-06
- 무엇을: `REV-P1-002`의 PRD §14 성공 기준 13개를 완료 증거와 동기화했다.
- 왜: PRD §14와 완료된 Phase 1 상태 불일치
- 어떻게:
- `rg -n "^- \\[x\\]" docs/20260806_수정요청변경필드만전송/prd.md` — §14 완료 항목 13개 확인, exit 0.
- `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md` — §18 요구사항 변경 체크리스트 5개만 유지, exit 0.
- 대상 PRD·API Contract·plan·review `test -f` — 누락 없음, exit 0.
- `git diff --check` — 출력 없음, exit 0.
- 남은 항목: 없음

View File

@@ -13,7 +13,7 @@ import { ReleaseScheduleField } from "@/features/audio-contents/components/Relea
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField"; import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { AudioContentDetail } from "@/features/audio-contents/model/types"; import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema"; import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema"; import type { AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy"; import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy";
import type { ApiClient } from "@/shared/api/client"; import type { ApiClient } from "@/shared/api/client";
import { ApiError } from "@/shared/api/api-error"; import { ApiError } from "@/shared/api/api-error";
@@ -53,6 +53,10 @@ const errorIds = {
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/; const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
function hasUpdateFields(request: AudioContentUpdateRequest): boolean {
return Object.keys(request).length > 0;
}
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) { export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
const [audioFile, setAudioFile] = useState<File | null>(null); const [audioFile, setAudioFile] = useState<File | null>(null);
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings); const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
@@ -76,7 +80,11 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
const isDeactivatingRef = useRef(false); const isDeactivatingRef = useRef(false);
const isSubmittingRef = useRef(false); const isSubmittingRef = useRef(false);
const contentId = audio === undefined ? null : String(audio.contentId); const contentId = audio === undefined ? null : String(audio.contentId);
const isDirty = title !== (audio?.title ?? "") || detail !== (audio?.detail ?? "") || tags !== (audio?.tag ?? "") || price !== createInitialPrice(audio?.price) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings; const parsedEditPrice = parsePrice(price);
const editRequest = mode === "edit" && audio !== undefined && parsedEditPrice !== null ? toUpdateRequest({ audio, detail: detail.trim(), price: parsedEditPrice, tags: tags.trim(), title: title.trim() }) : {};
const editHasChanges = mode === "edit" && (coverImage !== null || hasUpdateFields(editRequest) || (audio !== undefined && parsedEditPrice === null && price !== createInitialPrice(audio.price)));
const createHasChanges = mode === "create" && (title !== "" || detail !== "" || tags !== "" || price !== createInitialPrice(undefined) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings);
const isDirty = editHasChanges || createHasChanges;
const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null; const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null;
const isPaid = (parsePrice(price) ?? 0) > 0; const isPaid = (parsePrice(price) ?? 0) > 0;
@@ -146,6 +154,9 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
if (isSubmittingRef.current) { if (isSubmittingRef.current) {
return; return;
} }
if (mode === "edit" && !editHasChanges) {
return;
}
const nextErrors = validateForm(); const nextErrors = validateForm();
setErrors(nextErrors); setErrors(nextErrors);
@@ -172,7 +183,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
return; return;
} }
if (mode === "edit" && audio !== undefined && contentId !== null) { if (mode === "edit" && audio !== undefined && contentId !== null) {
await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: toUpdateRequest({ audio, detail: detail.trim(), price: parsedPrice, tags: tags.trim(), title: title.trim() }) }); await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: editRequest });
setUploadState({ progress: 100, status: "success" }); setUploadState({ progress: 100, status: "success" });
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." }); navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." });
} }
@@ -249,7 +260,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null} {mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null}
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button"></button> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked || (mode === "edit" && !editHasChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
</div> </div>
</form> </form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />} {cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />}

View File

@@ -112,13 +112,12 @@ export function toCreateRequest(params: { readonly detail: string; readonly pric
} }
export function toUpdateRequest(params: { readonly detail: string; readonly price: number; readonly tags: string; readonly title: string; readonly audio: AudioContentDetail }): AudioContentUpdateRequest { export function toUpdateRequest(params: { readonly detail: string; readonly price: number; readonly tags: string; readonly title: string; readonly audio: AudioContentDetail }): AudioContentUpdateRequest {
return { const request: AudioContentUpdateRequest = {
title: params.title, ...(params.title === params.audio.title.trim() ? {} : { title: params.title }),
detail: params.detail, ...(params.detail === params.audio.detail.trim() ? {} : { detail: params.detail }),
tags: params.tags, ...(params.tags === params.audio.tag.trim() ? {} : { tags: params.tags }),
price: params.price, ...(params.price === params.audio.price ? {} : { price: params.price }),
isAdult: params.audio.isAdult,
isPointAvailable: params.audio.isAvailableUsePoint,
isCommentAvailable: params.audio.isCommentAvailable,
}; };
return request;
} }

View File

@@ -58,7 +58,38 @@ test("AudioContentFormPage allows the maximum edit price", async () => {
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 }); expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
}); });
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => { test("AudioContentFormPage disables edit save when no field or cover changed", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
const saveButton = await screen.findByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage disables edit save when price formatting normalizes to the original value", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
const priceInput = await screen.findByLabelText("가격");
fireEvent.change(priceInput, { target: { value: "01000" } });
// Then
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "저장" }));
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage update sends only changed fields and soft delete navigates to the audio list", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit"); window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
@@ -88,11 +119,7 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
expect(await readJsonPart(updateBody.get("request"))).toEqual({ expect(await readJsonPart(updateBody.get("request"))).toEqual({
title: "수정 오디오", title: "수정 오디오",
detail: "수정 설명", detail: "수정 설명",
tags: "상담,힐링",
price: 0, price: 0,
isAdult: false,
isPointAvailable: true,
isCommentAvailable: true,
}); });
// When // When
@@ -194,12 +221,10 @@ test("AudioContentFormPage keeps existing edit cover when replacement crop is ca
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } }); fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } });
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" }); const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" })); fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001")); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
const updateRequest = requests.find((request) => request.method === "PUT"); expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
expect(requireFormData(updateRequest?.body).has("coverImage")).toBe(false);
}); });
test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => { test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => {
@@ -247,11 +272,13 @@ test("AudioContentFormPage ignores stale edit cover sources and saves the latest
await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "저장" })); fireEvent.click(screen.getByRole("button", { name: "저장" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001")); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
const coverPart = requireFormData(requests.at(-1)?.body).get("coverImage"); const updateBody = requireFormData(requests.at(-1)?.body);
const coverPart = updateBody.get("coverImage");
if (!(coverPart instanceof File)) { if (!(coverPart instanceof File)) {
throw new TypeError("Expected cover image file"); throw new TypeError("Expected cover image file");
} }
expect(coverPart.name).toBe("fresh.png"); expect(coverPart.name).toBe("fresh.png");
expect(await readJsonPart(updateBody.get("request"))).toEqual({});
}); });
test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => { test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => {
@@ -265,5 +292,5 @@ test("AudioContentFormPage shows an edit cover preparation error when preview cr
// Then // Then
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다."); expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
}); });

View File

@@ -8,6 +8,9 @@ type BackgroundRequest = NonNullable<CreateCharacterParams["request"]["backgroun
type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number]; type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number];
type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>; type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>;
type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] }; type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] };
type UpdateOptionalRequest = UpdateCharacterParams["request"];
type MutableUpdateOptionalRequest = { -readonly [Key in keyof UpdateOptionalRequest]: UpdateOptionalRequest[Key] };
const updateOptionalKeys = ["age", "gender", "mbti", "speechPattern", "speechStyle", "appearance", "originalTitle", "originalLink", "characterType", "tags", "hobbies", "values", "goals", "relationships", "personalities", "backgrounds", "memories"] as const satisfies readonly (keyof UpdateOptionalRequest)[];
function emptyRelationship(): RelationshipDraft { function emptyRelationship(): RelationshipDraft {
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" }; return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
@@ -123,7 +126,7 @@ export function toCreateCharacterOptionalRequest(value: CharacterOptionalFieldsV
return request; return request;
} }
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateCharacterParams["request"] { function toSerializedUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateOptionalRequest {
const tags = splitList(value.tags); const tags = splitList(value.tags);
const hobbies = splitList(value.hobbies); const hobbies = splitList(value.hobbies);
const values = splitList(value.values); const values = splitList(value.values);
@@ -139,3 +142,25 @@ export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsV
relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows, relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows,
}; };
} }
function isSameSerializedValue(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue, initialValue: CharacterOptionalFieldsValue): UpdateOptionalRequest {
const current = toSerializedUpdateCharacterOptionalRequest(value);
const initial = toSerializedUpdateCharacterOptionalRequest(initialValue);
const request: MutableUpdateOptionalRequest = {};
function addChanged<Key extends keyof UpdateOptionalRequest>(key: Key): void {
if (!isSameSerializedValue(current[key], initial[key])) {
request[key] = current[key];
}
}
for (const key of updateOptionalKeys) {
addChanged(key);
}
return request;
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { navigateTo } from "@/app/browser-location"; import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths"; import { routePaths } from "@/app/route-paths";
import { getCharacter, updateCharacter } from "@/features/characters/api/character-api"; import { getCharacter, updateCharacter } from "@/features/characters/api/character-api";
import type { UpdateCharacterParams } from "@/features/characters/api/character-api";
import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields"; import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields";
import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization"; import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField"; import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
@@ -64,6 +65,10 @@ function validateImage(image: File | null): string | undefined {
return "JPEG 또는 PNG 파일만 업로드하세요."; return "JPEG 또는 PNG 파일만 업로드하세요.";
} }
function hasRequestFields(request: UpdateCharacterParams["request"]): boolean {
return Object.keys(request).length > 0;
}
function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) { function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [description, setDescription] = useState(character.description); const [description, setDescription] = useState(character.description);
@@ -78,7 +83,19 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const imageSelectionId = useRef(0); const imageSelectionId = useRef(0);
const detailPath = routePaths.aiCharacterDetail(String(character.id)); const detailPath = routePaths.aiCharacterDetail(String(character.id));
const dirty = image !== null || isImagePreparing || cropSource !== null || description !== character.description || name !== character.name || originalWork?.id !== character.originalWork?.id || systemPrompt !== character.systemPrompt || JSON.stringify(optionalFields) !== JSON.stringify(characterOptionalFieldsFromDetail(character)); const initialOptionalFields = characterOptionalFieldsFromDetail(character);
const initialDescription = character.description.trim();
const initialName = character.name.trim();
const initialSystemPrompt = character.systemPrompt.trim();
const request: UpdateCharacterParams["request"] = {
...toUpdateCharacterOptionalRequest(optionalFields, initialOptionalFields),
...(description.trim() === initialDescription ? {} : { description: description.trim() }),
...(name.trim() === initialName ? {} : { name: name.trim() }),
...(originalWork !== null && originalWork.id !== character.originalWork?.id ? { originalWorkId: originalWork.id } : {}),
...(systemPrompt.trim() === initialSystemPrompt ? {} : { systemPrompt: systemPrompt.trim() }),
};
const hasChanges = image !== null || hasRequestFields(request);
const dirty = hasChanges || isImagePreparing || cropSource !== null;
const imageSubmitBlocked = isImagePreparing || cropSource !== null; const imageSubmitBlocked = isImagePreparing || cropSource !== null;
useEffect(() => () => cropSource?.release?.(), [cropSource]); useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -143,6 +160,9 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
if (isSubmitting) { if (isSubmitting) {
return; return;
} }
if (!hasChanges) {
return;
}
const nextErrors = validate(); const nextErrors = validate();
setErrors(nextErrors); setErrors(nextErrors);
@@ -155,13 +175,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
try { try {
await updateCharacter(apiClient, String(character.id), { await updateCharacter(apiClient, String(character.id), {
image: image ?? undefined, image: image ?? undefined,
request: { request,
...toUpdateCharacterOptionalRequest(optionalFields),
description: description.trim(),
name: name.trim(),
originalWorkId: originalWork?.id,
systemPrompt: systemPrompt.trim(),
},
}); });
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." }); navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
} catch (error: unknown) { } catch (error: unknown) {
@@ -208,7 +222,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} type="button"> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} type="button">
</button> </button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || imageSubmitBlocked} type="submit"> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || imageSubmitBlocked || !hasChanges} type="submit">
{isSubmitting ? "저장 중" : "저장"} {isSubmitting ? "저장 중" : "저장"}
</button> </button>
</div> </div>

View File

@@ -67,23 +67,12 @@ const inactiveCharacter: CharacterDetail = {
isActive: false, isActive: false,
}; };
const activeCharacterOptionalPayload = { const characterWithWhitespaceDirectFields: CharacterDetail = {
age: null, ...activeCharacter,
gender: null, name: " 루나 ",
mbti: null, description: " 차분한 상담형 캐릭터 ",
speechPattern: null, systemPrompt: " 친절하게 답한다. ",
speechStyle: null, };
appearance: null,
characterType: "Character",
tags: null,
hobbies: null,
values: null,
goals: null,
relationships: null,
personalities: null,
backgrounds: null,
memories: null,
} as const;
function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient { function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
return { return {
@@ -185,7 +174,7 @@ function renderOriginalCrop(request: CropRenderRequest): Promise<File> {
return Promise.resolve(request.file); return Promise.resolve(request.file);
} }
test("CharacterEditPage submits changed fields without region or active state", async () => { test("CharacterEditPage submits only the changed direct field", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit"); window.history.pushState({}, "", "/ai-characters/101/edit");
@@ -193,8 +182,6 @@ test("CharacterEditPage submits changed fields without region or active state",
// When // When
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } }); fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } });
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } });
fireEvent.click(screen.getByRole("button", { name: "저장" })); fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
@@ -203,12 +190,44 @@ test("CharacterEditPage submits changed fields without region or active state",
{ path: "/api/v2/admin/ai-characters/101", method: undefined }, { path: "/api/v2/admin/ai-characters/101", method: undefined },
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" }, { path: "/api/v2/admin/ai-characters/101", method: "PUT" },
]); ]);
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ name: "루나 수정" });
...activeCharacterOptionalPayload, });
name: "루나 수정",
systemPrompt: "짧고 안전하게 답한다.", test("CharacterEditPage disables save when no field or image changed", async () => {
description: "업데이트된 상담형 캐릭터", // Given
}); const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit");
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" />);
// When
const saveButton = await screen.findByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
});
test("CharacterEditPage compares direct fields after applying the same trim rule", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit");
render(<CharacterEditPage apiClient={createEditClient(requests, characterWithWhitespaceDirectFields)} characterId="101" />);
// When
const saveButton = await screen.findByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
// When
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나 수정" } });
expect(saveButton).toBeEnabled();
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
// Then
expect(saveButton).toBeDisabled();
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
}); });
test("CharacterEditPage blocks the direct edit route for inactive characters", async () => { test("CharacterEditPage blocks the direct edit route for inactive characters", async () => {
@@ -282,7 +301,7 @@ test("CharacterEditPage keeps entered values and retries after an ApiError rejec
expect(requests).toHaveLength(3); expect(requests).toHaveLength(3);
}); });
test("CharacterEditPage submits optional scalar and array fields without region", async () => { test("CharacterEditPage submits only changed optional scalar fields", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit"); window.history.pushState({}, "", "/ai-characters/101/edit");
@@ -296,26 +315,8 @@ test("CharacterEditPage submits optional scalar and array fields without region"
// Then // Then
await waitFor(() => expect(requests).toHaveLength(2)); await waitFor(() => expect(requests).toHaveLength(2));
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
age: "24",
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "부드러움",
appearance: "긴 머리",
originalTitle: "달빛 상담소", originalTitle: "달빛 상담소",
originalLink: "https://example.com/original", originalLink: "https://example.com/original",
characterType: "Character",
tags: ["상담", "힐링"],
hobbies: ["독서", "산책"],
values: ["안전"],
goals: ["도움"],
relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }],
personalities: [{ trait: "차분함", description: "침착하게 응대" }],
backgrounds: [{ topic: "출신", description: "달빛 상담소" }],
memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }],
}); });
}); });
@@ -350,9 +351,6 @@ test("CharacterEditPage serializes cleared optional scalar and array fields as n
// Then // Then
await waitFor(() => expect(requests).toHaveLength(2)); await waitFor(() => expect(requests).toHaveLength(2));
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
age: null, age: null,
gender: null, gender: null,
mbti: null, mbti: null,
@@ -422,6 +420,7 @@ test("CharacterEditPage crops a replacement image before update", async () => {
const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image")); const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image"));
expect(submittedImage.name).toBe("luna-cropped.png"); expect(submittedImage.name).toBe("luna-cropped.png");
expect(await submittedImage.text()).toBe("cropped"); expect(await submittedImage.text()).toBe("cropped");
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({});
}); });
test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => { test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => {
@@ -537,11 +536,10 @@ test("CharacterEditPage keeps the existing image when replacement crop is cancel
// When // When
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } }); fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
fireEvent.click(await screen.findByRole("button", { name: "취소" })); fireEvent.click(await screen.findByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
await waitFor(() => expect(requests).toHaveLength(2)); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
expect(requireFormData(requests[1]?.body).get("image")).toBeNull(); expect(requests).toHaveLength(1);
expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument();
}); });
@@ -563,15 +561,9 @@ test("P10-T1 CharacterEditPage searches v2 original works and omits originalWork
fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" })); fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" }));
expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument(); expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" })); fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
await waitFor(() => expect(requests).toHaveLength(3)); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
expect(requests).toHaveLength(2);
expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B"); expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B");
expect(await readJsonPart(requireFormData(requests[2]?.body).get("request"))).toEqual({
...activeCharacterOptionalPayload,
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
});
}); });

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api"; import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api";
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy"; import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
import type { CommunityPostListItem } from "@/features/community-posts/model/types"; import type { CommunityPostListItem, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
import { CommentThread } from "@/features/comments/components/CommentThread"; import { CommentThread } from "@/features/comments/components/CommentThread";
import { ApiError } from "@/shared/api/api-error"; import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client"; import type { ApiClient } from "@/shared/api/client";
@@ -29,7 +29,19 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const imageSelectionId = useRef(0); const imageSelectionId = useRef(0);
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true); const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
const updateRequest: CommunityPostUpdateRequest = {};
if (content !== post.content) {
updateRequest.content = content;
}
if (isAdult !== post.isAdult) {
updateRequest.isAdult = isAdult;
}
if (isCommentAvailable !== post.isCommentAvailable) {
updateRequest.isCommentAvailable = isCommentAvailable;
}
const hasPostChanges = Object.keys(updateRequest).length > 0 || postImage !== null;
const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null; const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null;
const isSaveDisabled = isMutationDisabled || imageErrorMessage !== undefined || !hasPostChanges;
useEffect(() => () => cropSource?.release?.(), [cropSource]); useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -38,14 +50,14 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
} }
async function savePost() { async function savePost() {
if (isSavingRef.current || imageErrorMessage !== undefined || isImagePreparing || cropSource !== null) { if (isSavingRef.current || isSaveDisabled) {
return; return;
} }
isSavingRef.current = true; isSavingRef.current = true;
setIsSaving(true); setIsSaving(true);
setErrorMessage(null); setErrorMessage(null);
try { try {
await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: { content, isAdult, isCommentAvailable, isFixed: post.isFixed } }); await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: updateRequest });
onMutated(); onMutated();
} catch (error: unknown) { } catch (error: unknown) {
setErrorMessage(getMutationErrorMessage(error)); setErrorMessage(getMutationErrorMessage(error));
@@ -191,7 +203,7 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
{post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm"> : {post.firstComment.comment}</p>} {post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm"> : {post.firstComment.comment}</p>}
{post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null} {post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null}
{canMutate ? <div className="flex flex-wrap gap-2"> {canMutate ? <div className="flex flex-wrap gap-2">
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void savePost()} type="button"> </button> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaveDisabled} onClick={() => void savePost()} type="button"> </button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button>
<button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button"></button> <button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button"></button>
</div> : null} </div> : null}

View File

@@ -111,7 +111,7 @@ test("Community Sheet sends actual multipart update requests without audio or pr
const updateBody = requireFormData(requests[0]?.body); const updateBody = requireFormData(requests[0]?.body);
expect(updateBody.has("audioFile")).toBe(false); expect(updateBody.has("audioFile")).toBe(false);
expect(updateBody.has("price")).toBe(false); expect(updateBody.has("price")).toBe(false);
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false, isFixed: false }); expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false });
// When // When
fireEvent.click(screen.getByRole("button", { name: "비활성화" })); fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
@@ -129,6 +129,41 @@ test("Community Sheet sends actual multipart update requests without audio or pr
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false }); expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false });
}); });
test("Community Sheet disables save when no field or image changed", () => {
// Given
const requests: CapturedApiRequest[] = [];
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
// When
const saveButton = screen.getByRole("button", { name: "수정 저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(requests).toHaveLength(0);
});
test("Community Sheet preserves whitespace-only content changes", async () => {
// Given
const requests: CapturedApiRequest[] = [];
const changedContent = ` ${basePost.content} `;
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: changedContent } });
const saveButton = screen.getByRole("button", { name: "수정 저장" });
// Then
expect(saveButton).not.toBeDisabled();
// When
fireEvent.click(saveButton);
// Then
await waitFor(() => expect(requests).toHaveLength(1));
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({ content: changedContent });
});
test("Community Sheet keeps save impossible while image preparation is pending", async () => { test("Community Sheet keeps save impossible while image preparation is pending", async () => {
// Given // Given
let resolveCropSource: (source: CropSourceImage) => void = () => undefined; let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
@@ -175,6 +210,7 @@ test("Community Sheet ignores stale image preparation results", async () => {
await waitFor(() => expect(requests).toHaveLength(1)); await waitFor(() => expect(requests).toHaveLength(1));
expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif); expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif);
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({});
}); });
test("Community Sheet resets saving state and shows an alert when mutation fails", async () => { test("Community Sheet resets saving state and shows an alert when mutation fails", async () => {
@@ -191,6 +227,7 @@ test("Community Sheet resets saving state and shows an alert when mutation fails
render(<App />); render(<App />);
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]); fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "저장 실패 테스트" } });
fireEvent.click(screen.getByRole("button", { name: "수정 저장" })); fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패"); expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패");

View File

@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control"; import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) { export function FanTalkReplyForm({ content, errorMessage, isSaving, isSubmitDisabled = false, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly isSubmitDisabled?: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) {
const errorId = "fan-talk-reply-error"; const errorId = "fan-talk-reply-error";
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null); const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null);
@@ -39,7 +39,7 @@ export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, on
}} value={content} /> }} value={content} />
</label> </label>
{visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>} {visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>}
<button className="w-fit rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving} type="submit"> <button className="w-fit rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isSubmitDisabled} type="submit">
{submitLabel} {submitLabel}
</button> </button>
</form> </form>

View File

@@ -36,9 +36,10 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true); const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
const visibleReply = savedReply ?? existingReply; const visibleReply = savedReply ?? existingReply;
const isEditing = !startedWithoutReply && existingReply !== null; const isEditing = !startedWithoutReply && existingReply !== null;
const isReplyChanged = existingReply === null || content !== existingReply.content;
async function submitReply() { async function submitReply() {
if (isSavingRef.current) { if (isSavingRef.current || (isEditing && !isReplyChanged)) {
return; return;
} }
@@ -113,7 +114,7 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p> <p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
</section> </section>
{successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>} {successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>}
{visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />} {visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} isSubmitDisabled={isEditing && !isReplyChanged} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />}
{visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>} {visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
<button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk </button> <button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk </button>
</div> </div>

View File

@@ -216,6 +216,47 @@ test("FanTalk reply form creates unanswered items with POST and edits existing r
expect(screen.getAllByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })[0]).toBeInTheDocument(); expect(screen.getAllByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })[0]).toBeInTheDocument();
}); });
test("FanTalk reply edit only submits when content changed", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const state = { items: [pendingFanTalk, answeredFanTalk] };
await renderFanTalkPage(state, requests);
clickFirstButton("두 번째로 온 응원입니다. 답변 보기");
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
const contentInput = within(dialog).getByLabelText("답변 내용");
const submitButton = within(dialog).getByRole("button", { name: "답변 수정" });
// When / Then
expect(submitButton).toBeDisabled();
fireEvent.click(submitButton);
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
// When
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
// Then
expect(submitButton).not.toBeDisabled();
// When
fireEvent.change(contentInput, { target: { value: "이미 답변했습니다." } });
// Then
expect(submitButton).toBeDisabled();
fireEvent.click(submitButton);
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
// When
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
fireEvent.click(submitButton);
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
});
test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => { test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => {
// Given // Given
saveAdminSession(); saveAdminSession();

View File

@@ -6,7 +6,7 @@ import { createSeries, deactivateSeries, updateSeries } from "@/features/series/
import { GenreCombobox } from "@/features/series/components/GenreCombobox"; import { GenreCombobox } from "@/features/series/components/GenreCombobox";
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField"; import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types"; import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
import type { SeriesPublishedDay, SeriesState } from "@/features/series/schemas/series-schema"; import type { SeriesPublishedDay, SeriesState, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy"; import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
import { ApiError } from "@/shared/api/api-error"; import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client"; import type { ApiClient } from "@/shared/api/client";
@@ -69,10 +69,6 @@ function dayError(days: readonly SeriesPublishedDay[]): string | undefined {
return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined; return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined;
} }
function editedState(state: SeriesState, series: SeriesListItem | undefined): SeriesState | undefined {
return state === (series?.state ?? "PROCEEDING") ? undefined : state;
}
function hasErrors(errors: FieldErrors): boolean { function hasErrors(errors: FieldErrors): boolean {
return Object.values(errors).some((error) => error !== undefined); return Object.values(errors).some((error) => error !== undefined);
} }
@@ -100,8 +96,40 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
const isDeactivatingRef = useRef(false); const isDeactivatingRef = useRef(false);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const seriesId = series === undefined ? null : String(series.seriesId); const seriesId = series === undefined ? null : String(series.seriesId);
const dirty = image !== null || title !== (series?.title ?? "") || introduction !== (series?.introduction ?? "") || keyword !== "" || genreId !== (series?.genreId ?? null) || isAdult !== (series?.isAdult ?? false) || publishedDays.join(",") !== (series?.publishedDaysOfWeek ?? []).join(",") || state !== (series?.state ?? "PROCEEDING") || studio !== (series?.studio ?? "") || writer !== (series?.writer ?? "");
const isImageSubmitBlocked = isImagePreparing || cropSource !== null; const isImageSubmitBlocked = isImagePreparing || cropSource !== null;
const editRequest: SeriesUpdateRequest = {};
if (mode === "edit" && series !== undefined) {
const nextTitle = title.trim();
const nextIntroduction = introduction.trim();
const nextWriter = textOrNull(writer);
const nextStudio = textOrNull(studio);
if (nextTitle !== series.title.trim()) {
editRequest.title = nextTitle;
}
if (nextIntroduction !== series.introduction.trim()) {
editRequest.introduction = nextIntroduction;
}
if (publishedDays.join(",") !== series.publishedDaysOfWeek.join(",")) {
editRequest.publishedDaysOfWeek = [...publishedDays];
}
if (genreId !== series.genreId) {
editRequest.genreId = genreId;
}
if (isAdult !== series.isAdult) {
editRequest.isAdult = isAdult;
}
if (state !== series.state) {
editRequest.state = state;
}
if (nextWriter !== textOrNull(series.writer ?? "")) {
editRequest.writer = nextWriter;
}
if (nextStudio !== textOrNull(series.studio ?? "")) {
editRequest.studio = nextStudio;
}
}
const hasEditChanges = image !== null || Object.keys(editRequest).length > 0;
const dirty = mode === "create" ? image !== null || title !== "" || introduction !== "" || keyword !== "" || genreId !== null || isAdult || publishedDays.length > 0 || studio !== "" || writer !== "" : hasEditChanges;
useEffect(() => () => cropSource?.release?.(), [cropSource]); useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -157,7 +185,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
async function submit(event: { readonly preventDefault: () => void }): Promise<void> { async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
event.preventDefault(); event.preventDefault();
if (isSavingRef.current) { if (isSavingRef.current || (mode === "edit" && !hasEditChanges)) {
return; return;
} }
const nextErrors = validate(); const nextErrors = validate();
@@ -175,7 +203,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
return; return;
} }
if (mode === "edit" && seriesId !== null) { if (mode === "edit" && seriesId !== null) {
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], genreId, isAdult, state: editedState(state, series), writer: textOrNull(writer), studio: textOrNull(studio) } }); await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: editRequest });
navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." }); navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." });
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -234,7 +262,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null} {mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null}
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} type="button"></button> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isImageSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isImageSubmitBlocked || (mode === "edit" && !hasEditChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
</div> </div>
</form> </form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />} {cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />}

View File

@@ -88,9 +88,8 @@ test("SeriesForm keeps existing edit image when replacement crop is canceled", a
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" })); fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" })); fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001")); expect(window.location.pathname).toBe("/ai-characters/101/series/5001/edit");
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData; expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
expect(updateBody.get("image")).toBeNull();
}); });
test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => { test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => {

View File

@@ -194,7 +194,7 @@ test("SeriesForm edits enum fields without keyword or image requirement and deac
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true)); await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData; const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(updateBody.get("image")).toBeNull(); expect(updateBody.get("image")).toBeNull();
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "달빛 상담 시리즈", introduction: "밤마다 이어지는 상담 에피소드", publishedDaysOfWeek: ["RANDOM"], genreId: 77, isAdult: false, state: "COMPLETE", writer: "스튜디오 루나", studio: "소다랩" })); await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ publishedDaysOfWeek: ["RANDOM"], state: "COMPLETE" }));
expect(window.location.pathname).toBe("/ai-characters/101/series/5001"); expect(window.location.pathname).toBe("/ai-characters/101/series/5001");
// When // When
@@ -249,15 +249,59 @@ test("SeriesForm omits unchanged state from edit payload", async () => {
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true)); await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({ await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({
title: "달빛 상담 시리즈 수정", title: "달빛 상담 시리즈 수정",
introduction: "밤마다 이어지는 상담 에피소드",
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
writer: "스튜디오 루나",
studio: "소다랩",
}); });
}); });
test("SeriesForm disables edit save when unchanged", () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
const saveButton = screen.getByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("SeriesForm sends null when optional writer is cleared", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "" } });
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({ writer: null });
});
test("SeriesForm sends empty request when only image changes", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("changed.png")] } });
await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("cropped-series.png");
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
const requestBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(requestBody.get("image")).toBeInstanceOf(File);
await expect(readRequestPart(requestBody)).resolves.toEqual({});
});
test("SeriesForm rejects mismatched image extension and MIME before crop", async () => { test("SeriesForm rejects mismatched image extension and MIME before crop", async () => {
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
let cropSourceCalls = 0; let cropSourceCalls = 0;