test #442
@@ -259,9 +259,11 @@
|
|||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
- RED: 데뷔 후 30일 이내 추천 점수순, 최근 데뷔 크리에이터 노출 정보의 프로필 이미지/닉네임, 첫 오디오 콘텐츠 3번째 이내 활성 콘텐츠만 인정, 최신성 점수 구간별 정렬, 예약 공개 콘텐츠 제외 테스트를 작성한다.
|
- RED: 데뷔 후 30일 이내 추천 점수순, 최근 데뷔 크리에이터 노출 정보의 프로필 이미지/닉네임, 첫 오디오 콘텐츠 3번째 이내 활성 콘텐츠만 인정, 최신성 점수 구간별 정렬, 예약 공개 콘텐츠 제외 테스트를 작성한다.
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
- GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다.
|
- GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다. 최근 데뷔 크리에이터의 라이브 기준 데뷔 판정은 종료된 라이브도 유지되는 `live_room.channel_name` 존재 여부를 기준으로 하며, 종료 시 `false`가 되는 `live_room.is_active`는 조건으로 사용하지 않는다.
|
||||||
- REFACTOR: 데뷔일 계산은 `CreatorDebutPolicy`, 산식은 `RecommendationScorePolicy`만 호출하도록 중복 제거한다.
|
- REFACTOR: 데뷔일 계산은 `CreatorDebutPolicy`, 산식은 `RecommendationScorePolicy`만 호출하도록 중복 제거한다.
|
||||||
- 기대 결과: 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠가 제외된다.
|
- 기대 결과: 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠가 제외된다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-22: 종료된 라이브도 `channel_name`이 있으면 최근 데뷔 크리에이터의 라이브 데뷔로 인정하도록 `DefaultHomeRecommendationQueryRepositoryTest.shouldIncludeEndedLiveWithChannelNameInRecentDebutCreators`를 추가했다. RED에서 기존 SQL의 `lr.is_active = true` 조건 때문에 실패했고, GREEN에서 `findRecentDebutCreators`의 live 데뷔 branch가 `channel_name` 기준만 사용하도록 수정해 focused test가 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
|
||||||
- [x] **Task 3.3: AI 캐릭터/응원/인기 커뮤니티 스냅샷 조회 구현**
|
- [x] **Task 3.3: AI 캐릭터/응원/인기 커뮤니티 스냅샷 조회 구현**
|
||||||
- Files:
|
- Files:
|
||||||
@@ -293,6 +295,30 @@
|
|||||||
- 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT`와 `HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
- 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT`와 `HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
- 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck`와 `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다.
|
- 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck`와 `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다.
|
||||||
|
|
||||||
|
- [x] **Task 9.2: 홈 첫 화면 AI 캐릭터 부족분 랜덤 보충**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
|
||||||
|
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- RED: 스냅샷 상세 조립 결과가 요청 limit 미만이면 스냅샷 target id를 제외하고 랜덤 AI 캐릭터 id를 부족분만큼 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와, 랜덤 id 조회가 활성 `ChatCharacter` 및 활성 CREATOR/AI_CHARACTER `creatorMember`만 반환하고 제외 id를 빼는 repository 테스트를 작성한다.
|
||||||
|
- 실패 확인:
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
|
||||||
|
```
|
||||||
|
- GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, `HomeRecommendationQueryService.findAiCharacterRecommendations`에서 보충이 명시적으로 켜져 있으며 상세 결과가 `limit`보다 적을 때만 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(excludeCharacterIds, limit)`로 부족분 id를 먼저 뽑은 뒤 기존 `findAiCharacterRecommendationDetails(...)`로 상세와 채팅 수를 조회한다. `excludeCharacterIds`는 최신 스냅샷 target id 전체를 사용해 stale 스냅샷 후보를 다시 뽑지 않는다.
|
||||||
|
- REFACTOR: 전체보기 paging 조회는 랜덤 보충을 적용하지 않고, AI 캐릭터 추천 점수 산식/스냅샷 생성/공개 API schema는 변경하지 않는다.
|
||||||
|
- 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters`는 가능한 경우 20개까지 채워지고, 랜덤 후보가 부족하면 중복 없이 가능한 AI 캐릭터만 반환한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-22: RED에서 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(...)` 계약을 추가하고 service/repository 테스트를 작성했으며, 기존 구현은 `DefaultHomeRecommendationQueryRepository`가 신규 port 메서드를 구현하지 않아 `compileKotlin`에서 실패했다.
|
||||||
|
- 2026-07-22: GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달하고, AI 캐릭터 스냅샷 상세 결과가 limit보다 적을 때 랜덤 AI 캐릭터 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용하도록 구현했다. 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 랜덤 보충하지 않도록 기존 계약을 유지했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
- 2026-07-22: 리뷰 지적에 따라 전체보기 첫 페이지가 `offset == 0`으로 랜덤 보충되는 문제를 `backfillRandom` 명시 플래그로 보정했다. 재검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했다.
|
||||||
|
|
||||||
### Phase 4: 콘텐츠 조회 이력 기록
|
### Phase 4: 콘텐츠 조회 이력 기록
|
||||||
|
|
||||||
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
|
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||||
- 데뷔일은 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
|
- 데뷔일은 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
|
||||||
- 데뷔일 계산 로직은 기존 `ExplorerService.getCreatorDetail`의 `debutDateTime` 계산 방식과 동일하게 맞춘다.
|
- 데뷔일 계산 로직은 기존 `ExplorerService.getCreatorDetail`의 `debutDateTime` 계산 방식과 동일하게 맞춘다.
|
||||||
|
- 라이브 기준 데뷔 판정은 `live_room.channel_name`이 존재하고 빈 값이 아닌 라이브를 사용한다. 종료된 라이브는 `live_room.is_active = false`가 되므로 최근 데뷔 판정에서 `is_active`는 조건으로 사용하지 않는다.
|
||||||
- 데뷔 후 30일 이내 크리에이터만 대상으로 한다.
|
- 데뷔 후 30일 이내 크리에이터만 대상으로 한다.
|
||||||
- 추천 점수는 `((팔로우 증가량 * 0.35) + (콘텐츠 활동 점수 * 0.3) + (소통 점수 * 0.2)) * 신규 부스트`로 계산한다.
|
- 추천 점수는 `((팔로우 증가량 * 0.35) + (콘텐츠 활동 점수 * 0.3) + (소통 점수 * 0.2)) * 신규 부스트`로 계산한다.
|
||||||
- 팔로우 증가량은 최근 7일간 신규 팔로우한 유저 수로 계산한다.
|
- 팔로우 증가량은 최근 7일간 신규 팔로우한 유저 수로 계산한다.
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
- AI 캐릭터 리스트를 조회한다.
|
- AI 캐릭터 리스트를 조회한다.
|
||||||
- 홈 첫 화면은 20개를 조회한다.
|
- 홈 첫 화면은 20개를 조회한다.
|
||||||
|
- 홈 첫 화면은 최신 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
|
||||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||||
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
||||||
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
|
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
|
||||||
@@ -181,6 +183,8 @@
|
|||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 비활성 또는 노출 제한 캐릭터는 제외한다.
|
- 비활성 또는 노출 제한 캐릭터는 제외한다.
|
||||||
- 활성 `ChatCharacter`에 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다.
|
- 활성 `ChatCharacter`에 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다.
|
||||||
|
- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 내려준다.
|
||||||
|
- 전체 리스트 API의 paging 조회는 페이지별 랜덤 보충을 적용하지 않는다.
|
||||||
|
|
||||||
### Feature H. 장르의 크리에이터
|
### Feature H. 장르의 크리에이터
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
- 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다.
|
- 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다.
|
||||||
- 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다.
|
- 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다.
|
||||||
- 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다.
|
- 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다.
|
||||||
|
- 추가: 홈 통합 조회에서 최신 스냅샷 상세 결과가 20개 미만이면 스냅샷 캐릭터 id를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
|
||||||
- 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다.
|
- 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다.
|
||||||
- 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다.
|
- 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다.
|
||||||
- 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다.
|
- 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다.
|
||||||
@@ -217,6 +218,21 @@
|
|||||||
|
|
||||||
### Phase 7: 최종 검증과 문서 갱신
|
### Phase 7: 최종 검증과 문서 갱신
|
||||||
|
|
||||||
|
- [x] **Task 6.3: 홈 통합 AI 캐릭터 부족분 랜덤 보충**
|
||||||
|
- 파일 경로:
|
||||||
|
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`
|
||||||
|
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- RED: 새 `findRandomAiCharacterRecommendationIds(...)` port 계약을 추가하고, 스냅샷 상세 결과가 limit 미만이면 부족분만큼 랜덤 id를 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와 제외 id/활성 AI creator member 조건을 검증하는 repository 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, 보충이 명시적으로 켜져 있으며 스냅샷이 존재하고 상세 결과가 limit 미만인 경우에만 랜덤 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용한다.
|
||||||
|
- REFACTOR: 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 기존 빈 결과/paging 계약을 유지한다.
|
||||||
|
- 기대 결과: 홈 통합 AI 캐릭터 섹션은 가능한 경우 20개까지 채워지고, 공개 API 응답 필드는 변경되지 않는다.
|
||||||
|
|
||||||
- [x] **Task 7.1: focused regression 실행**
|
- [x] **Task 7.1: focused regression 실행**
|
||||||
- 파일 경로:
|
- 파일 경로:
|
||||||
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
|
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
|
||||||
@@ -246,7 +262,7 @@
|
|||||||
- Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다.
|
- Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다.
|
||||||
- Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다.
|
- Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다.
|
||||||
- Feature C: Task 2.2, Task 3.2에서 `ChatCharacter.createdAt` 기준 AI 전용 신규 부스트와 null/future 제외를 검증한다.
|
- Feature C: Task 2.2, Task 3.2에서 `ChatCharacter.createdAt` 기준 AI 전용 신규 부스트와 null/future 제외를 검증한다.
|
||||||
- Feature D: Task 3.3, Task 5.3, Task 6.1에서 top 20, 동점 생성일 최신순, 기존 응답 스키마 유지를 검증한다.
|
- Feature D: Task 3.3, Task 5.3, Task 6.1, Task 6.3에서 top 20, 동점 생성일 최신순, 홈 통합 부족분 랜덤 보충, 기존 응답 스키마 유지를 검증한다.
|
||||||
- Feature E: Task 5.1, Task 5.2, Task 5.3에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다.
|
- Feature E: Task 5.1, Task 5.2, Task 5.3에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다.
|
||||||
- Non-Goals: Task 6.1과 Task 7.2에서 공개 API URL/응답 필드 변경 없음, AI 캐릭터 팔로우 생성/취소 동작 변경 없음, 관리자/ML/A-B 제외를 확인한다.
|
- Non-Goals: Task 6.1과 Task 7.2에서 공개 API URL/응답 필드 변경 없음, AI 캐릭터 팔로우 생성/취소 동작 변경 없음, 관리자/ML/A-B 제외를 확인한다.
|
||||||
|
|
||||||
@@ -258,6 +274,9 @@
|
|||||||
- 2026-07-10: 구현 검증으로 산식/윈도우 focused 테스트 `RecommendationScorePolicyTest`, `RecommendationSnapshotWindowPolicyTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
- 2026-07-10: 구현 검증으로 산식/윈도우 focused 테스트 `RecommendationScorePolicyTest`, `RecommendationSnapshotWindowPolicyTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
- 2026-07-10: AI 캐릭터 스냅샷 집계 focused 테스트 `DefaultHomeRecommendationQueryRepositoryTest`를 실행해 전날 UTC window, 팔로우 증가량, 후보 제외, top 20/동점 정렬이 `BUILD SUCCESSFUL`임을 확인했다.
|
- 2026-07-10: AI 캐릭터 스냅샷 집계 focused 테스트 `DefaultHomeRecommendationQueryRepositoryTest`를 실행해 전날 UTC window, 팔로우 증가량, 후보 제외, top 20/동점 정렬이 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
- 2026-07-10: refresh/fallback/query focused 테스트 `RecommendationSnapshotRefreshServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`를 실행해 section lock, 300ms lock wait, 1,500ms home wait, timeout 후 background 완료, double-check, fallback 연결이 `BUILD SUCCESSFUL`임을 확인했다.
|
- 2026-07-10: refresh/fallback/query focused 테스트 `RecommendationSnapshotRefreshServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`를 실행해 section lock, 300ms lock wait, 1,500ms home wait, timeout 후 background 완료, double-check, fallback 연결이 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
|
- 2026-07-22: 사용자 피드백에 따라 홈 통합 AI 캐릭터 섹션의 스냅샷 상세 결과가 20개 미만이면 랜덤 활성 AI 캐릭터로 부족분을 보충하도록 PRD와 plan-task를 보강했다. RED에서 신규 port 계약 미구현으로 `DefaultHomeRecommendationQueryRepository` 컴파일이 실패했고, GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달해 랜덤 id 조회와 기존 상세 조회 재사용 조건을 추가했다. focused 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-07-22: 리뷰 지적에 따라 랜덤 보충 쿼리가 전체 AI 캐릭터의 채팅 메시지를 집계한 뒤 `RAND()`/`LIMIT`하지 않도록 2단계 조회로 보정했다. 랜덤 쿼리는 활성 AI 캐릭터 id만 limit만큼 선택하고, 상세/전체 채팅 수는 기존 `findAiCharacterRecommendationDetails(...)` 경로를 재사용한다. RED에서 기존 production 구현의 `findRandomAiCharacterRecommendationDetails(...)` 참조로 `compileKotlin`이 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`와 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
- 2026-07-22: 리뷰 게이트에서 전체보기 첫 페이지가 `offset == 0` 조건으로 랜덤 보충되는 blocker를 확인해 `backfillRandom` 명시 플래그로 홈 통합 조회와 전체보기 조회를 분리했다. 재검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했고, 재리뷰에서 blocker 없이 승인받았다.
|
||||||
- 2026-07-10: API 회귀 focused 테스트 `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`를 실행해 공개 응답 스키마 유지가 `BUILD SUCCESSFUL`임을 확인했다.
|
- 2026-07-10: API 회귀 focused 테스트 `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`를 실행해 공개 응답 스키마 유지가 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
- 2026-07-10: 전체 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 120초 제한에서 1회 timeout되어 600초 제한으로 재실행했고, 모든 명령이 최종 `BUILD SUCCESSFUL` 또는 무출력 통과했다.
|
- 2026-07-10: 전체 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 120초 제한에서 1회 timeout되어 600초 제한으로 재실행했고, 모든 명령이 최종 `BUILD SUCCESSFUL` 또는 무출력 통과했다.
|
||||||
- 2026-07-10: 리뷰 게이트에서 발견된 fallback 조건/단일 실행/section lock 해제 시점 이슈를 수정했다. 최신 스냅샷 존재 여부는 요청 page가 아니라 `offset=0, limit=1` 존재 확인으로 분리했고, fallback refresh는 단일 in-flight future로 제한했으며, scheduler AI section lock은 트랜잭션 완료 후 해제되도록 보강했다.
|
- 2026-07-10: 리뷰 게이트에서 발견된 fallback 조건/단일 실행/section lock 해제 시점 이슈를 수정했다. 최신 스냅샷 존재 여부는 요청 page가 아니라 `offset=0, limit=1` 존재 확인으로 분리했고, fallback refresh는 단일 in-flight future로 제한했으며, scheduler AI section lock은 트랜잭션 완료 후 해제되도록 보강했다.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
- 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다.
|
- 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다.
|
||||||
- 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
|
- 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
|
||||||
- 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다.
|
- 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다.
|
||||||
|
- 홈 통합 조회는 최신 스냅샷 상세 조회 결과가 20개 미만이면 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,11 +118,14 @@
|
|||||||
- 사용자들이 친 전체 채팅 수
|
- 사용자들이 친 전체 채팅 수
|
||||||
- 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다.
|
- 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다.
|
||||||
- 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다.
|
- 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다.
|
||||||
|
- 홈 통합 조회에서 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터 id를 제외하고 활성 AI 캐릭터를 랜덤으로 조회해 부족분을 뒤에 붙인다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다.
|
- 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다.
|
||||||
- fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다.
|
- fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다.
|
||||||
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
|
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
|
||||||
|
- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 반환한다.
|
||||||
|
- 빈 스냅샷 refresh 완료 marker가 있거나 전체보기 paging 조회이면 랜덤 보충을 적용하지 않는다.
|
||||||
|
|
||||||
### Feature E. 스냅샷 없음 fallback refresh
|
### Feature E. 스냅샷 없음 fallback refresh
|
||||||
|
|
||||||
|
|||||||
395
docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md
Normal file
395
docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# Frontend Original Work Management Add-on Prompt
|
||||||
|
|
||||||
|
이 문서는 이미 구현 또는 구현 중인 AI 캐릭터 관리자 Frontend에 원작 관리 기능만 추가하기 위한 delta 프롬프트다.
|
||||||
|
기존 PRD 27.8의 stack, 인증·세션, 환경별 API Base URL, Jenkins, noindex, UTC/KST, feedback와 공통 API 규칙은 그대로 유지한다.
|
||||||
|
|
||||||
|
## Copy-Paste Prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
당신은 기존 AI 캐릭터 관리자 Frontend에 원작 관리 기능을 추가한다.
|
||||||
|
|
||||||
|
[작업 원칙]
|
||||||
|
- 기존 기술 stack, package version, lockfile, VITE_API_BASE_URL mode 설정과 Jenkins 명령을 변경하지 않는다.
|
||||||
|
- .env.development와 .env.production은 같은 VITE_API_BASE_URL key에 서로 다른 실제 dev·production URL을 유지하고 source code에 URL을 하드코딩하지 않는다.
|
||||||
|
- Jenkins의 install·검증 명령은 기존 pnpm install --frozen-lockfile, pnpm run ci:prod를 그대로 사용한다.
|
||||||
|
- 기존 로그인, sessionStorage, fetch wrapper, ApiResponse envelope, error handling, noindex, UTC/KST와 Sonner 규칙을 재사용한다.
|
||||||
|
- 아래 실제 Request/Response JSON과 명시된 nullable JSON 값으로 TypeScript type, API 함수와 mock을 만든다. Backend DTO/data class의 존재나 이름을 전제하지 않는다.
|
||||||
|
- legacy /admin/chat/original/**와 /api/chat/original/**를 호출하지 않는다.
|
||||||
|
- 메뉴는 Backend에서 조회하지 않고 기존 typed static menu configuration에 추가한다.
|
||||||
|
- 새 원작 route에도 기존 접근 제어와 noindex 정책을 동일하게 적용하고 검색 노출 가능한 public route나 metadata를 만들지 않는다.
|
||||||
|
- 도메인별 화면을 범용 CRUD 설정 하나로 합치지 않는다. 두 화면 이상에서 실제 반복되는 UI와 동작만 component로 추출한다.
|
||||||
|
|
||||||
|
[완료 목표]
|
||||||
|
- global 원작 목록·검색·상세·등록·수정·삭제 화면을 제공한다.
|
||||||
|
- 원작 상세에서 연결된 활성·비활성 캐릭터를 조회하고, 활성 AI 캐릭터를 배정하며 기존 연결을 해제할 수 있게 한다.
|
||||||
|
- 기존 캐릭터 등록·수정 form에서 원작을 검색·선택하거나 연결을 해제할 수 있게 한다.
|
||||||
|
- 아래 ORIGINAL-WORK-01~08을 기존 API client, query key, 화면과 test에 연결한다.
|
||||||
|
- 기존 58개 신규 관리자 Operation에 8개를 더한 66개 신규 관리자 Operation을 브라우저에서 사용할 수 있게 한다.
|
||||||
|
|
||||||
|
[메뉴와 route]
|
||||||
|
- 기존 typed static menu에 다음 global item을 추가한다.
|
||||||
|
{ key: "original-works", label: "원작 관리", to: "/ai-characters/original-works", scope: "GLOBAL" }
|
||||||
|
- 원작 관리는 character-scoped 메뉴가 아니며 진입 전에 캐릭터를 선택하지 않는다.
|
||||||
|
- route는 다음을 사용한다.
|
||||||
|
/ai-characters/original-works
|
||||||
|
/ai-characters/original-works/new
|
||||||
|
/ai-characters/original-works/:originalWorkId
|
||||||
|
/ai-characters/original-works/:originalWorkId/edit
|
||||||
|
- /ai-characters/original-works의 static segment가 /ai-characters/:characterId보다 우선 매칭되는지 route test로 고정한다.
|
||||||
|
|
||||||
|
[Page와 Dialog 결정]
|
||||||
|
- 원작 목록, 상세, 등록, 수정은 Page로 만든다. 서버 페이징, URL 복원, 이미지와 다중 field form이 있으므로 Dialog로 만들지 않는다.
|
||||||
|
- 원작 상세의 캐릭터 배정은 검색·다중 선택 Dialog로 만든다.
|
||||||
|
- 연결 캐릭터 해제는 table row action 또는 table bulk action으로 제공하고 실행 전에 AlertDialog로 확인한다.
|
||||||
|
- 원작 삭제는 AlertDialog로 확인한다. characterCount가 0보다 크면 UI에서 이유를 표시하고 비활성화하되, race condition에 대한 Backend 409도 처리한다.
|
||||||
|
- 캐릭터 등록·수정 form의 원작 선택은 form 안의 searchable Combobox 또는 선택 Dialog로 구현한다. 별도 사전 선택 Page를 추가하지 않는다.
|
||||||
|
|
||||||
|
[캐릭터 등록·수정의 원작 선택]
|
||||||
|
- ORIGINAL-WORK-01의 page, size, search를 사용해 isDeleted=false 원작을 검색한다.
|
||||||
|
- 캐릭터 생성 form의 선택값이 없으면 CHAR-03 request.originalWorkId에 null을 보낸다.
|
||||||
|
- 캐릭터 수정 form은 CHAR-02 response.originalWork의 id, title, imageUrl로 현재 선택을 복원한다.
|
||||||
|
- CHAR-02 response.originalWork 자체는 null일 수 있다. null이면 현재 원작 미선택 상태로 복원하고 ORIGINAL-WORK-02를 호출하지 않는다.
|
||||||
|
- 수정 form에서 선택을 지우면 CHAR-04 request.originalWorkId에 명시적 null을 보낸다.
|
||||||
|
- originalWorkId=0을 보내지 않는다.
|
||||||
|
- CHAR-04는 기존 계약대로 originalWorkId key 자체를 반드시 포함한다.
|
||||||
|
- 수정 진입 시 현재 선택된 원작이 첫 검색 page에 없어도 CHAR-02의 brief를 선택값으로 유지하고, 사용자가 검색 결과를 선택할 때만 교체한다.
|
||||||
|
- 현재 brief의 ORIGINAL-WORK-02가 404이면 legacy 데이터의 삭제·누락 원작 연결로 표시하고, 새 원작 선택 또는 명시적 해제 전에는 수정을 제출하지 않는다.
|
||||||
|
|
||||||
|
[원작 form]
|
||||||
|
- 생성 기본값은 isAdult=false, description="", nullable string=null, originalLinks=[], tags=[]다.
|
||||||
|
- 수정 요청은 아래 11개 JSON key를 항상 모두 보낸다.
|
||||||
|
title, contentType, category, isAdult, description, originalWork, originalLink,
|
||||||
|
writer, studio, originalLinks, tags
|
||||||
|
- nullable string을 지울 때 null, 링크와 태그를 모두 지울 때 []를 보낸다. update key를 생략하지 않는다.
|
||||||
|
- title, contentType, category는 trim 후 필수다.
|
||||||
|
- originalLink와 originalLinks는 http/https 절대 URL만 허용한다.
|
||||||
|
- originalLinks와 tags는 trim하고 빈 값을 제거하며 첫 등장 순서를 유지해 중복을 제거한다.
|
||||||
|
- create의 image는 필수, update의 image는 선택이다. update에서 새 image를 선택하지 않으면 image part를 보내지 않는다.
|
||||||
|
- multipart request part에는 아래 Request JSON을 JSON.stringify한 문자열을 넣고 multipart 전체 Content-Type은 직접 지정하지 않는다.
|
||||||
|
|
||||||
|
[재사용 component]
|
||||||
|
- OriginalWorkForm: create와 edit Page가 공유하되 create/update API 호출은 각 Page에 둔다.
|
||||||
|
- OriginalWorkSelector: 캐릭터 create/edit form이 공유한다.
|
||||||
|
- OriginalWorkSummaryCell: 원작 목록과 캐릭터 form의 선택 결과가 공유한다.
|
||||||
|
- AssignedCharacterTable: 원작 상세의 연결 캐릭터 목록과 bulk selection을 담당한다.
|
||||||
|
- CharacterAssignmentDialog: CHAR-01 검색과 ORIGINAL-WORK-07 호출을 담당한다.
|
||||||
|
- 기존 Pagination, DataTable, PageHeader, LoadingState, EmptyState, ErrorState, FormErrorSummary, Confirm AlertDialog를 재사용한다.
|
||||||
|
- 원작 전용 validation과 캐릭터 배정 규칙을 generic CRUD schema로 추상화하지 않는다.
|
||||||
|
|
||||||
|
[Query key와 invalidation]
|
||||||
|
- 목록: ["original-works", { page, size, search }]
|
||||||
|
- 상세: ["original-work", originalWorkId]
|
||||||
|
- 연결 캐릭터: ["original-work", originalWorkId, "characters", { page, size, search, isActive }]
|
||||||
|
- 캐릭터 배정 후보는 기존 CHAR-01 global query를 isActive=true로 조회한다.
|
||||||
|
- ORIGINAL-WORK-03 성공 후 원작 목록을 invalidate한다.
|
||||||
|
- ORIGINAL-WORK-04 성공 후 해당 상세와 원작 목록을 invalidate하고, title/image brief가 바뀔 수 있으므로 ["ai-character"] prefix의 CHAR-02 상세 query도 invalidate한다.
|
||||||
|
- ORIGINAL-WORK-05 성공 후 해당 상세를 제거하고 목록으로 이동한 뒤 원작 목록을 invalidate한다.
|
||||||
|
- ORIGINAL-WORK-07/08 성공 후 원작 목록 전체와 ["original-work"] prefix의 상세·연결 캐릭터 query를 invalidate한다. 배정은 이전 원작에서 이동할 수 있으므로 새 원작 cache만 갱신하지 않는다. 응답 characterIds 각각의 CHAR-02 query도 invalidate한다.
|
||||||
|
- CHAR-03/04 성공 후 새 원작과 이전 원작이 있으면 해당 원작 상세·연결 캐릭터 목록·목록의 characterCount를 invalidate한다.
|
||||||
|
- CHAR-05 성공 후 삭제 캐릭터가 연결된 원작의 연결 캐릭터 목록과 characterCount를 invalidate한다.
|
||||||
|
|
||||||
|
[배정·해제 UX]
|
||||||
|
- 배정 후보는 CHAR-01의 활성 AI 캐릭터만 표시한다.
|
||||||
|
- 배정 확인문에 “다른 원작에 연결된 캐릭터는 이 원작으로 이동합니다”를 명시한다.
|
||||||
|
- ORIGINAL-WORK-07은 선택한 characterIds 전체를 한 요청으로 전송한다.
|
||||||
|
- ORIGINAL-WORK-08은 DELETE method와 JSON body를 함께 사용하고 Content-Type: application/json을 명시한다.
|
||||||
|
- 배정·해제에서 일부 성공을 가정하지 않는다. 성공 Response 뒤에만 선택을 비우고 toast를 표시한다.
|
||||||
|
- 배정·해제 성공 Response의 characterIds는 실제 변경 여부와 관계없이 검증된 요청 ID 전체가 요청 순서대로 반환된다. characterCount는 처리 건수가 아니라 작업 후 해당 원작에 연결된 전체 캐릭터 수다.
|
||||||
|
- 연결 목록의 creatorId는 legacy 불일치 정리를 위해 null일 수 있다. null이면 캐릭터 row는 계속 표시하고 “연결 크리에이터 없음” 상태를 표시하며 해제 action을 숨기지 않는다.
|
||||||
|
- 400 errorProperty=characterIds는 Dialog의 selection error로 표시한다.
|
||||||
|
- 409 errorProperty=title은 trim·대소문자 무시 중복 제목 field error로 표시한다.
|
||||||
|
- 409 errorProperty=characterIds는 비활성 상태 변경 가능성을 설명하고 후보 목록을 refetch한다.
|
||||||
|
- 409 errorProperty=originalWorkId는 삭제된 원작 또는 연결 캐릭터가 남은 삭제 요청으로 처리하고 상세·연결 목록을 refetch한다.
|
||||||
|
- originalWorkId와 원작 route param은 양수만 전송한다. 0 이하는 client validation으로 차단하고, 404 originalWorkId는 누락·삭제된 조회 대상으로, mutation의 409 originalWorkId는 삭제 상태 또는 연결 존재 충돌로 구분해 처리한다.
|
||||||
|
- 이미 삭제가 성공한 원작의 DELETE 재시도는 같은 성공 Response가 올 수 있으므로 오류로 간주하지 않는다.
|
||||||
|
|
||||||
|
[날짜와 feedback]
|
||||||
|
- createdAtUtc와 updatedAtUtc는 UTC Z 원문으로 cache·비교하고 기존 formatUtcInKst utility로 KST 표시한다.
|
||||||
|
- mutation 성공은 기존 Sonner success toast, field 오류는 inline, 최초 조회 실패는 ErrorState, 삭제·해제 사전 확인은 AlertDialog 규칙을 유지한다.
|
||||||
|
- characterCount에는 활성·비활성 및 연결 creator 상태와 관계없이 모든 연결 캐릭터가 포함되므로 UI에서 임의로 다시 계산하지 않는다.
|
||||||
|
|
||||||
|
[응답 nullable JSON 값]
|
||||||
|
- 아래 값은 기존 Operation Response JSON의 해당 위치에 나타날 수 있는 유효 JSON이다. 예시의 non-null 값만 보고 required string/number로 좁히지 않는다.
|
||||||
|
- ORIGINAL-WORK-01 data.items[0]과 ORIGINAL-WORK-02 data의 imageUrl, createdAtUtc, updatedAtUtc는 null일 수 있다.
|
||||||
|
- ORIGINAL-WORK-02 data의 originalWork, originalLink, writer, studio는 null일 수 있다.
|
||||||
|
- ORIGINAL-WORK-06 data.items[0]의 creatorId, imageUrl, createdAtUtc는 null일 수 있다.
|
||||||
|
- CHAR-02 data.originalWork는 아래 null 또는 brief 객체다. brief의 imageUrl도 null일 수 있다.
|
||||||
|
|
||||||
|
ORIGINAL-WORK-01 data.items[0] nullable JSON:
|
||||||
|
{
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"title": "달빛 도서관",
|
||||||
|
"contentType": "WEB_NOVEL",
|
||||||
|
"category": "FANTASY",
|
||||||
|
"isAdult": false,
|
||||||
|
"imageUrl": null,
|
||||||
|
"characterCount": 2,
|
||||||
|
"createdAtUtc": null,
|
||||||
|
"updatedAtUtc": null
|
||||||
|
}
|
||||||
|
|
||||||
|
ORIGINAL-WORK-02 data nullable JSON:
|
||||||
|
{
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"title": "달빛 도서관",
|
||||||
|
"contentType": "WEB_NOVEL",
|
||||||
|
"category": "FANTASY",
|
||||||
|
"isAdult": false,
|
||||||
|
"description": "",
|
||||||
|
"originalWork": null,
|
||||||
|
"originalLink": null,
|
||||||
|
"writer": null,
|
||||||
|
"studio": null,
|
||||||
|
"originalLinks": [],
|
||||||
|
"tags": [],
|
||||||
|
"imageUrl": null,
|
||||||
|
"characterCount": 0,
|
||||||
|
"createdAtUtc": null,
|
||||||
|
"updatedAtUtc": null
|
||||||
|
}
|
||||||
|
|
||||||
|
ORIGINAL-WORK-06 data.items[0] nullable JSON:
|
||||||
|
{
|
||||||
|
"characterId": 101,
|
||||||
|
"creatorId": null,
|
||||||
|
"name": "루나",
|
||||||
|
"imageUrl": null,
|
||||||
|
"isActive": false,
|
||||||
|
"createdAtUtc": null
|
||||||
|
}
|
||||||
|
|
||||||
|
CHAR-02 data.originalWork nullable JSON:
|
||||||
|
null
|
||||||
|
|
||||||
|
CHAR-02 data.originalWork brief nullable JSON:
|
||||||
|
{
|
||||||
|
"id": 71,
|
||||||
|
"title": "달빛 도서관",
|
||||||
|
"imageUrl": null
|
||||||
|
}
|
||||||
|
|
||||||
|
[Operation Catalog]
|
||||||
|
공통 성공 envelope key는 success, message, data, errorProperty다.
|
||||||
|
공통 page data key는 items, page, size, totalCount, hasNext다.
|
||||||
|
공통 오류 Response JSON:
|
||||||
|
{"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":"originalWorkId"}
|
||||||
|
배정·해제 field 오류 Response JSON:
|
||||||
|
{"success":false,"message":"캐릭터 선택을 확인해 주세요.","data":null,"errorProperty":"characterIds"}
|
||||||
|
중복 제목 오류 Response JSON:
|
||||||
|
{"success":false,"message":"이미 사용 중인 원작 제목입니다.","data":null,"errorProperty":"title"}
|
||||||
|
|
||||||
|
ORIGINAL-WORK-01 GET /admin/ai-characters/original-works
|
||||||
|
Path: 없음
|
||||||
|
Query: page=0&size=20&search=달빛
|
||||||
|
Request JSON: 없음
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"items": [{
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"title": "달빛 도서관",
|
||||||
|
"contentType": "WEB_NOVEL",
|
||||||
|
"category": "FANTASY",
|
||||||
|
"isAdult": false,
|
||||||
|
"imageUrl": "https://cdn.example.com/originals/71/original.webp",
|
||||||
|
"characterCount": 2,
|
||||||
|
"createdAtUtc": "2026-07-20T01:00:00Z",
|
||||||
|
"updatedAtUtc": "2026-07-20T02:00:00Z"
|
||||||
|
}],
|
||||||
|
"page": 0,
|
||||||
|
"size": 20,
|
||||||
|
"totalCount": 1,
|
||||||
|
"hasNext": false
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
search는 title, contentType, category의 대소문자 무시 부분 검색이다.
|
||||||
|
|
||||||
|
ORIGINAL-WORK-02 GET /admin/ai-characters/original-works/{originalWorkId}
|
||||||
|
Request JSON: 없음
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"title": "달빛 도서관",
|
||||||
|
"contentType": "WEB_NOVEL",
|
||||||
|
"category": "FANTASY",
|
||||||
|
"isAdult": false,
|
||||||
|
"description": "밤에만 문을 여는 도서관의 이야기",
|
||||||
|
"originalWork": "Moonlight Library",
|
||||||
|
"originalLink": "https://example.com/works/71",
|
||||||
|
"writer": "김작가",
|
||||||
|
"studio": "소다 스튜디오",
|
||||||
|
"originalLinks": [
|
||||||
|
"https://example.com/works/71",
|
||||||
|
"https://example.com/works/71/official"
|
||||||
|
],
|
||||||
|
"tags": ["힐링", "판타지"],
|
||||||
|
"imageUrl": "https://cdn.example.com/originals/71/original.webp",
|
||||||
|
"characterCount": 2,
|
||||||
|
"createdAtUtc": "2026-07-20T01:00:00Z",
|
||||||
|
"updatedAtUtc": "2026-07-20T02:00:00Z"
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
|
||||||
|
ORIGINAL-WORK-03 POST /admin/ai-characters/original-works
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
Parts: image 필수, request 필수 JSON string
|
||||||
|
Request JSON:
|
||||||
|
{
|
||||||
|
"title": "달빛 도서관",
|
||||||
|
"contentType": "WEB_NOVEL",
|
||||||
|
"category": "FANTASY",
|
||||||
|
"isAdult": false,
|
||||||
|
"description": "밤에만 문을 여는 도서관의 이야기",
|
||||||
|
"originalWork": "Moonlight Library",
|
||||||
|
"originalLink": "https://example.com/works/71",
|
||||||
|
"writer": "김작가",
|
||||||
|
"studio": "소다 스튜디오",
|
||||||
|
"originalLinks": [
|
||||||
|
"https://example.com/works/71",
|
||||||
|
"https://example.com/works/71/official"
|
||||||
|
],
|
||||||
|
"tags": ["힐링", "판타지"]
|
||||||
|
}
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"isDeleted": false
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
|
||||||
|
ORIGINAL-WORK-04 PUT /admin/ai-characters/original-works/{originalWorkId}
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
Parts: image 선택, request 필수 JSON string
|
||||||
|
Request JSON:
|
||||||
|
{
|
||||||
|
"title": "달빛 도서관 개정판",
|
||||||
|
"contentType": "WEB_NOVEL",
|
||||||
|
"category": "FANTASY",
|
||||||
|
"isAdult": false,
|
||||||
|
"description": "개정된 작품 소개",
|
||||||
|
"originalWork": null,
|
||||||
|
"originalLink": null,
|
||||||
|
"writer": "김작가",
|
||||||
|
"studio": "소다 스튜디오",
|
||||||
|
"originalLinks": [],
|
||||||
|
"tags": ["판타지"]
|
||||||
|
}
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"isDeleted": false
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
|
||||||
|
ORIGINAL-WORK-05 DELETE /admin/ai-characters/original-works/{originalWorkId}
|
||||||
|
Request JSON: 없음
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"isDeleted": true
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
삭제되지 않은 원작에 연결된 캐릭터가 하나라도 있으면 409다. 이미 삭제된 원작의 재시도는 같은 성공 Response다.
|
||||||
|
|
||||||
|
ORIGINAL-WORK-06 GET /admin/ai-characters/original-works/{originalWorkId}/characters
|
||||||
|
Query: page=0&size=20&search=루나&isActive=true
|
||||||
|
Request JSON: 없음
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"items": [{
|
||||||
|
"characterId": 101,
|
||||||
|
"creatorId": 10001,
|
||||||
|
"name": "루나",
|
||||||
|
"imageUrl": "https://cdn.example.com/characters/101.webp",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAtUtc": "2026-07-20T01:30:00Z"
|
||||||
|
}],
|
||||||
|
"page": 0,
|
||||||
|
"size": 20,
|
||||||
|
"totalCount": 1,
|
||||||
|
"hasNext": false
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
isActive를 생략하면 활성·비활성 연결 캐릭터를 모두 반환한다. creatorId는 null일 수 있다.
|
||||||
|
|
||||||
|
ORIGINAL-WORK-07 POST /admin/ai-characters/original-works/{originalWorkId}/characters
|
||||||
|
Content-Type: application/json
|
||||||
|
Request JSON:
|
||||||
|
{
|
||||||
|
"characterIds": [101, 102]
|
||||||
|
}
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"characterIds": [101, 102],
|
||||||
|
"characterCount": 2
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
|
||||||
|
ORIGINAL-WORK-08 DELETE /admin/ai-characters/original-works/{originalWorkId}/characters
|
||||||
|
Content-Type: application/json
|
||||||
|
Request JSON:
|
||||||
|
{
|
||||||
|
"characterIds": [101, 102]
|
||||||
|
}
|
||||||
|
Response JSON:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": null,
|
||||||
|
"data": {
|
||||||
|
"originalWorkId": 71,
|
||||||
|
"characterIds": [101, 102],
|
||||||
|
"characterCount": 0
|
||||||
|
},
|
||||||
|
"errorProperty": null
|
||||||
|
}
|
||||||
|
|
||||||
|
[필수 test]
|
||||||
|
- static 원작 route가 dynamic :characterId route보다 우선한다.
|
||||||
|
- 원작 목록의 page, search, empty, loading, error와 KST 날짜 표시가 동작한다.
|
||||||
|
- create는 image를 필수로 보내고 update는 선택 image를 생략할 수 있다.
|
||||||
|
- update가 nullable key를 누락하지 않고 null과 []를 정확히 직렬화한다.
|
||||||
|
- 원작 삭제가 characterCount>0에서 UI상 차단되고 Backend 409 race도 표시한다.
|
||||||
|
- 배정이 다른 원작 연결 이동 경고를 표시하고 성공 뒤 관련 query를 invalidate한다.
|
||||||
|
- 같은 원작 반복 배정에서도 Response characterIds가 요청 순서를 유지하고 characterCount를 처리 건수로 오인하지 않는다.
|
||||||
|
- creatorId=null인 연결 캐릭터를 fallback 상태로 표시하고 해제할 수 있다.
|
||||||
|
- DELETE body 해제가 정확한 method, header와 JSON body를 전송한다.
|
||||||
|
- 캐릭터 생성에서 미선택 null, 수정에서 현재 원작 복원, 명시적 해제 null을 전송하고 0을 보내지 않는다.
|
||||||
|
- CHAR-02 originalWork=null을 미선택 상태로 복원하고, 원작·연결 캐릭터 Response의 모든 nullable field가 null이어도 화면과 typecheck가 정상 동작한다.
|
||||||
|
- 401, 403, 400 originalWorkId/characterIds, 404 originalWorkId, 409 title/originalWorkId/characterIds와 최초 조회 실패가 기존 feedback 규칙을 따른다.
|
||||||
|
- API가 반환한 UTC Z를 유지하고 화면에서만 Asia/Seoul로 표시한다.
|
||||||
|
|
||||||
|
[검증과 결과물]
|
||||||
|
- 기존 package scripts와 Jenkins의 pnpm install --frozen-lockfile, pnpm run ci:prod 경로가 그대로 통과해야 한다.
|
||||||
|
- typecheck, ESLint, unit/UI test와 production build를 모두 실행한다.
|
||||||
|
- 핵심 E2E에 원작 CRUD, 캐릭터 배정·해제, 캐릭터 form의 원작 선택·해제를 추가한다.
|
||||||
|
- README 또는 기존 API 문서에 원작 route, 8개 Operation, DELETE JSON body와 캐시 무효화 규칙을 추가한다.
|
||||||
|
- 구현하지 못한 항목은 숨기지 말고 Operation ID와 이유를 명시한다.
|
||||||
|
```
|
||||||
1021
docs/20260720_AI캐릭터_관리자기능/plan-task.md
Normal file
1021
docs/20260720_AI캐릭터_관리자기능/plan-task.md
Normal file
File diff suppressed because it is too large
Load Diff
4626
docs/20260720_AI캐릭터_관리자기능/prd.md
Normal file
4626
docs/20260720_AI캐릭터_관리자기능/prd.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.common
|
package kr.co.vividnext.sodalive.common
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
class SodaException(
|
class SodaException(
|
||||||
message: String? = null,
|
message: String? = null,
|
||||||
val errorProperty: String? = null,
|
val errorProperty: String? = null,
|
||||||
val messageKey: String? = null
|
val messageKey: String? = null,
|
||||||
|
val httpStatus: HttpStatus? = null
|
||||||
) : RuntimeException(message)
|
) : RuntimeException(message)
|
||||||
|
|
||||||
class AdsChargeException(
|
class AdsChargeException(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.access.AccessDeniedException
|
import org.springframework.security.access.AccessDeniedException
|
||||||
import org.springframework.security.authentication.BadCredentialsException
|
import org.springframework.security.authentication.BadCredentialsException
|
||||||
import org.springframework.security.authentication.InternalAuthenticationServiceException
|
import org.springframework.security.authentication.InternalAuthenticationServiceException
|
||||||
@@ -13,7 +14,9 @@ import org.springframework.web.bind.annotation.ExceptionHandler
|
|||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
||||||
|
import org.springframework.web.multipart.MultipartException
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class SodaExceptionHandler(
|
class SodaExceptionHandler(
|
||||||
@@ -38,12 +41,25 @@ class SodaExceptionHandler(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(MaxUploadSizeExceededException::class)
|
@ExceptionHandler(MaxUploadSizeExceededException::class, MultipartException::class)
|
||||||
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
|
fun handleMultipartException(e: MultipartException, request: HttpServletRequest) = run {
|
||||||
val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang)
|
val isAiCharacterAdminPath = isAiCharacterAdminPath(request.requestURI)
|
||||||
|
val messageKey = if (e is MaxUploadSizeExceededException) {
|
||||||
|
"common.error.max_upload_size"
|
||||||
|
} else if (isAiCharacterAdminPath) {
|
||||||
|
"common.error.invalid_request"
|
||||||
|
} else {
|
||||||
|
"common.error.unknown"
|
||||||
|
}
|
||||||
|
val logMessage = messageSource.getMessage(messageKey, logLang)
|
||||||
logger.error("API error: {}", logMessage, e)
|
logger.error("API error: {}", logMessage, e)
|
||||||
val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang)
|
val message = messageSource.getMessage(messageKey, langContext.lang)
|
||||||
ApiResponse.error(message = message)
|
val body = ApiResponse.error(message = message)
|
||||||
|
if (isAiCharacterAdminPath) {
|
||||||
|
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(AccessDeniedException::class)
|
@ExceptionHandler(AccessDeniedException::class)
|
||||||
@@ -99,4 +115,12 @@ class SodaExceptionHandler(
|
|||||||
val message = messageSource.getMessage("common.error.unknown", langContext.lang)
|
val message = messageSource.getMessage("common.error.unknown", langContext.lang)
|
||||||
ApiResponse.error(message)
|
ApiResponse.error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isAiCharacterAdminPath(requestUri: String): Boolean {
|
||||||
|
return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
|||||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
import kr.co.vividnext.sodalive.jwt.JwtFilter
|
import kr.co.vividnext.sodalive.jwt.JwtFilter
|
||||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
@@ -27,7 +30,9 @@ class SecurityConfig(
|
|||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val tokenProvider: TokenProvider,
|
private val tokenProvider: TokenProvider,
|
||||||
private val accessDeniedHandler: JwtAccessDeniedHandler,
|
private val accessDeniedHandler: JwtAccessDeniedHandler,
|
||||||
private val authenticationEntryPoint: JwtAuthenticationEntryPoint
|
private val authenticationEntryPoint: JwtAuthenticationEntryPoint,
|
||||||
|
private val aiCharacterAdminAuthenticationEntryPoint: ObjectProvider<AiCharacterAdminAuthenticationEntryPoint>,
|
||||||
|
private val aiCharacterAdminAccessDeniedHandler: ObjectProvider<AiCharacterAdminAccessDeniedHandler>
|
||||||
) {
|
) {
|
||||||
@Bean
|
@Bean
|
||||||
fun passwordEncoder(): PasswordEncoder {
|
fun passwordEncoder(): PasswordEncoder {
|
||||||
@@ -52,8 +57,22 @@ class SecurityConfig(
|
|||||||
.and()
|
.and()
|
||||||
.csrf().disable()
|
.csrf().disable()
|
||||||
.exceptionHandling()
|
.exceptionHandling()
|
||||||
.authenticationEntryPoint(authenticationEntryPoint)
|
.authenticationEntryPoint { request, response, authException ->
|
||||||
.accessDeniedHandler(accessDeniedHandler)
|
val adminEntryPoint = aiCharacterAdminAuthenticationEntryPoint.getIfAvailable()
|
||||||
|
if (isAiCharacterAdminPath(request.requestURI) && adminEntryPoint != null) {
|
||||||
|
adminEntryPoint.commence(request, response, authException)
|
||||||
|
} else {
|
||||||
|
authenticationEntryPoint.commence(request, response, authException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessDeniedHandler { request, response, accessDeniedException ->
|
||||||
|
val adminAccessDeniedHandler = aiCharacterAdminAccessDeniedHandler.getIfAvailable()
|
||||||
|
if (isAiCharacterAdminPath(request.requestURI) && adminAccessDeniedHandler != null) {
|
||||||
|
adminAccessDeniedHandler.handle(request, response, accessDeniedException)
|
||||||
|
} else {
|
||||||
|
accessDeniedHandler.handle(request, response, accessDeniedException)
|
||||||
|
}
|
||||||
|
}
|
||||||
.and()
|
.and()
|
||||||
.headers()
|
.headers()
|
||||||
.frameOptions()
|
.frameOptions()
|
||||||
@@ -100,6 +119,7 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||||
|
.antMatchers(HttpMethod.PUT, "/audio-content/upload-complete").hasAnyRole("ADMIN", "BOT")
|
||||||
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
|
||||||
@@ -110,8 +130,17 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
|
||||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||||
|
.antMatchers(AI_CHARACTER_ADMIN_PATH_PREFIX, "$AI_CHARACTER_ADMIN_PATH_PREFIX/**").hasRole("ADMIN")
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
.and()
|
.and()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isAiCharacterAdminPath(requestUri: String): Boolean {
|
||||||
|
return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.access.AccessDeniedException
|
||||||
|
import org.springframework.security.web.access.AccessDeniedHandler
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AiCharacterAdminAccessDeniedHandler(
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) : AccessDeniedHandler {
|
||||||
|
override fun handle(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
accessDeniedException: AccessDeniedException
|
||||||
|
) {
|
||||||
|
response.status = HttpStatus.FORBIDDEN.value()
|
||||||
|
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||||
|
response.characterEncoding = Charsets.UTF_8.name()
|
||||||
|
response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("권한이 없습니다.")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.core.AuthenticationException
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AiCharacterAdminAuthenticationEntryPoint(
|
||||||
|
private val objectMapper: ObjectMapper
|
||||||
|
) : AuthenticationEntryPoint {
|
||||||
|
override fun commence(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
authException: AuthenticationException
|
||||||
|
) {
|
||||||
|
response.status = HttpStatus.UNAUTHORIZED.value()
|
||||||
|
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||||
|
response.characterEncoding = Charsets.UTF_8.name()
|
||||||
|
response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("로그인 정보를 확인해주세요.")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AdminImagePartValidator {
|
||||||
|
fun validate(image: MultipartFile, allowGif: Boolean): AdminValidatedImage {
|
||||||
|
if (image.isEmpty) {
|
||||||
|
throw invalidImage(image.name)
|
||||||
|
}
|
||||||
|
if (image.size > MAX_IMAGE_BYTES) {
|
||||||
|
throw invalidImage(image.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = image.bytes
|
||||||
|
val signatureFormat = detectSignatureFormat(bytes) ?: throw invalidImage(image.name)
|
||||||
|
if (signatureFormat == "gif" && !allowGif) {
|
||||||
|
throw invalidImage(image.name)
|
||||||
|
}
|
||||||
|
if (!hasBoundedContainer(bytes = bytes, format = signatureFormat)) {
|
||||||
|
throw invalidImage(image.name)
|
||||||
|
}
|
||||||
|
val format = detectFormat(bytes) ?: throw invalidImage(image.name)
|
||||||
|
|
||||||
|
val contentType = when (format) {
|
||||||
|
"jpg" -> "image/jpeg"
|
||||||
|
else -> "image/$format"
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdminValidatedImage(
|
||||||
|
bytes = bytes,
|
||||||
|
contentType = contentType,
|
||||||
|
extension = format
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectSignatureFormat(bytes: ByteArray): String? {
|
||||||
|
return when {
|
||||||
|
bytes.size >= PNG_SIGNATURE.size && bytes.take(PNG_SIGNATURE.size) == PNG_SIGNATURE.toList() -> "png"
|
||||||
|
bytes.size >= 3 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte() -> "jpg"
|
||||||
|
bytes.size >= 6 && bytes.copyOfRange(0, 6).decodeToString() in setOf("GIF87a", "GIF89a") -> "gif"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectFormat(bytes: ByteArray): String? {
|
||||||
|
ImageIO.createImageInputStream(ByteArrayInputStream(bytes)).use { stream ->
|
||||||
|
if (stream == null) return null
|
||||||
|
return try {
|
||||||
|
val readers = ImageIO.getImageReaders(stream)
|
||||||
|
if (!readers.hasNext()) return null
|
||||||
|
val reader = readers.next()
|
||||||
|
try {
|
||||||
|
reader.setInput(stream, false, true)
|
||||||
|
val format = normalizeFormat(reader.formatName) ?: return null
|
||||||
|
val frameCount = validatedFrameCount(reader = reader, format = format, bytes = bytes) ?: return null
|
||||||
|
|
||||||
|
repeat(frameCount) { index ->
|
||||||
|
val readParam = reader.defaultReadParam
|
||||||
|
readParam.sourceRegion = Rectangle(0, 0, 1, 1)
|
||||||
|
reader.read(index, readParam) ?: return null
|
||||||
|
}
|
||||||
|
format
|
||||||
|
} finally {
|
||||||
|
reader.dispose()
|
||||||
|
}
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
null
|
||||||
|
} catch (e: IndexOutOfBoundsException) {
|
||||||
|
null
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasBoundedContainer(bytes: ByteArray, format: String): Boolean {
|
||||||
|
return when (format) {
|
||||||
|
"png" -> hasBoundedPngChunks(bytes)
|
||||||
|
"gif" -> hasBoundedGifBlocks(bytes)
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasBoundedPngChunks(bytes: ByteArray): Boolean {
|
||||||
|
var offset = PNG_SIGNATURE.size
|
||||||
|
var chunkCount = 0
|
||||||
|
var ancillaryBytes = 0L
|
||||||
|
|
||||||
|
while (offset + PNG_CHUNK_HEADER_BYTES + PNG_CHUNK_CRC_BYTES <= bytes.size) {
|
||||||
|
if (++chunkCount > MAX_PNG_CHUNKS) return false
|
||||||
|
val length = readBigEndianInt(bytes = bytes, offset = offset)
|
||||||
|
if (length < 0) return false
|
||||||
|
val typeOffset = offset + 4
|
||||||
|
val dataOffset = typeOffset + 4
|
||||||
|
val nextOffset = dataOffset.toLong() + length.toLong() + PNG_CHUNK_CRC_BYTES.toLong()
|
||||||
|
if (nextOffset > bytes.size) return false
|
||||||
|
|
||||||
|
val isAncillary = (bytes[typeOffset].toInt() and 0x20) != 0
|
||||||
|
if (isAncillary) {
|
||||||
|
ancillaryBytes += length.toLong()
|
||||||
|
if (length > MAX_IMAGE_METADATA_BYTES || ancillaryBytes > MAX_IMAGE_METADATA_BYTES) return false
|
||||||
|
}
|
||||||
|
if (bytes.copyOfRange(typeOffset, typeOffset + 4).decodeToString() == "IEND") return nextOffset == bytes.size.toLong()
|
||||||
|
offset = nextOffset.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasBoundedGifBlocks(bytes: ByteArray): Boolean {
|
||||||
|
if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET + 3) return false
|
||||||
|
var offset = GIF_LOGICAL_SCREEN_END_OFFSET
|
||||||
|
val packed = bytes[offset].toInt() and 0xFF
|
||||||
|
offset += 3
|
||||||
|
if ((packed and 0x80) != 0) {
|
||||||
|
offset += 3 * (1 shl ((packed and 0x07) + 1))
|
||||||
|
if (offset > bytes.size) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameCount = 0
|
||||||
|
var extensionCount = 0
|
||||||
|
var extensionBytes = 0L
|
||||||
|
var framePixels = 0L
|
||||||
|
while (offset < bytes.size) {
|
||||||
|
when (bytes[offset++].toInt() and 0xFF) {
|
||||||
|
0x2C -> {
|
||||||
|
if (++frameCount > MAX_GIF_FRAMES || offset + GIF_IMAGE_DESCRIPTOR_BYTES > bytes.size) return false
|
||||||
|
val width = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 4)
|
||||||
|
val height = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 6)
|
||||||
|
if (!hasValidDimensions(width = width, height = height)) return false
|
||||||
|
framePixels += width.toLong() * height
|
||||||
|
if (framePixels > MAX_IMAGE_PIXELS) return false
|
||||||
|
|
||||||
|
val imagePacked = bytes[offset + 8].toInt() and 0xFF
|
||||||
|
offset += GIF_IMAGE_DESCRIPTOR_BYTES
|
||||||
|
if ((imagePacked and 0x80) != 0) {
|
||||||
|
offset += 3 * (1 shl ((imagePacked and 0x07) + 1))
|
||||||
|
if (offset > bytes.size) return false
|
||||||
|
}
|
||||||
|
if (offset >= bytes.size) return false
|
||||||
|
val minimumCodeSize = bytes[offset++].toInt() and 0xFF
|
||||||
|
val scanned = scanGifSubBlocks(bytes = bytes, offset = offset, maxBlocks = null, collectPayload = true)
|
||||||
|
?: return false
|
||||||
|
val payload = scanned.payload ?: return false
|
||||||
|
if (!hasExactGifLzwPixelCount(payload, minimumCodeSize, width.toLong() * height)) return false
|
||||||
|
offset = scanned.nextOffset
|
||||||
|
}
|
||||||
|
0x21 -> {
|
||||||
|
if (++extensionCount > MAX_GIF_EXTENSIONS || offset >= bytes.size) return false
|
||||||
|
val label = bytes[offset++].toInt() and 0xFF
|
||||||
|
val scanned = when (label) {
|
||||||
|
0xF9 -> scanGifGraphicControlExtension(bytes, offset)
|
||||||
|
0x01 -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 12)
|
||||||
|
0xFF -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 11)
|
||||||
|
else -> scanGifSubBlocks(
|
||||||
|
bytes = bytes,
|
||||||
|
offset = offset,
|
||||||
|
maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS,
|
||||||
|
collectPayload = false
|
||||||
|
)
|
||||||
|
} ?: return false
|
||||||
|
extensionBytes += scanned.payloadBytes
|
||||||
|
if (extensionBytes > MAX_IMAGE_METADATA_BYTES) return false
|
||||||
|
offset = scanned.nextOffset
|
||||||
|
}
|
||||||
|
0x3B -> return offset == bytes.size && frameCount > 0
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanGifSubBlocks(
|
||||||
|
bytes: ByteArray,
|
||||||
|
offset: Int,
|
||||||
|
maxBlocks: Int?,
|
||||||
|
collectPayload: Boolean
|
||||||
|
): GifSubBlockScan? {
|
||||||
|
var currentOffset = offset
|
||||||
|
var payloadBytes = 0L
|
||||||
|
var blockCount = 0
|
||||||
|
val payload = if (collectPayload) ByteArrayOutputStream() else null
|
||||||
|
while (currentOffset < bytes.size) {
|
||||||
|
val size = bytes[currentOffset++].toInt() and 0xFF
|
||||||
|
if (size == 0) {
|
||||||
|
return GifSubBlockScan(
|
||||||
|
nextOffset = currentOffset,
|
||||||
|
payloadBytes = payloadBytes,
|
||||||
|
payload = payload?.toByteArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (++blockCount > (maxBlocks ?: Int.MAX_VALUE)) return null
|
||||||
|
if (currentOffset + size > bytes.size) return null
|
||||||
|
payloadBytes += size.toLong()
|
||||||
|
payload?.write(bytes, currentOffset, size)
|
||||||
|
currentOffset += size
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanGifGraphicControlExtension(bytes: ByteArray, offset: Int): GifSubBlockScan? {
|
||||||
|
if (offset + 5 >= bytes.size || (bytes[offset].toInt() and 0xFF) != 4) return null
|
||||||
|
if (bytes[offset + 5].toInt() != 0) return null
|
||||||
|
return GifSubBlockScan(nextOffset = offset + 6, payloadBytes = 4, payload = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanGifFixedHeaderExtension(bytes: ByteArray, offset: Int, fixedHeaderSize: Int): GifSubBlockScan? {
|
||||||
|
if (offset >= bytes.size || (bytes[offset].toInt() and 0xFF) != fixedHeaderSize) return null
|
||||||
|
val scanned = scanGifSubBlocks(
|
||||||
|
bytes = bytes,
|
||||||
|
offset = offset + fixedHeaderSize + 1,
|
||||||
|
maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS,
|
||||||
|
collectPayload = false
|
||||||
|
) ?: return null
|
||||||
|
return scanned.copy(payloadBytes = scanned.payloadBytes + fixedHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasExactGifLzwPixelCount(data: ByteArray, minimumCodeSize: Int, expectedPixels: Long): Boolean {
|
||||||
|
if (minimumCodeSize !in MIN_GIF_LZW_CODE_SIZE..MAX_GIF_LZW_CODE_SIZE) return false
|
||||||
|
|
||||||
|
val clearCode = 1 shl minimumCodeSize
|
||||||
|
val endOfInformationCode = clearCode + 1
|
||||||
|
val codeLengths = IntArray(MAX_GIF_LZW_TABLE_SIZE)
|
||||||
|
repeat(clearCode) { codeLengths[it] = 1 }
|
||||||
|
val bitReader = GifLzwBitReader(data)
|
||||||
|
val maxCodes = expectedPixels * 2L + 2L
|
||||||
|
var codeCount = 0L
|
||||||
|
var codeSize = minimumCodeSize + 1
|
||||||
|
var nextCode = endOfInformationCode + 1
|
||||||
|
var previousCode = -1
|
||||||
|
var decodedPixels = 0L
|
||||||
|
|
||||||
|
while (++codeCount <= maxCodes) {
|
||||||
|
val code = bitReader.read(codeSize) ?: return false
|
||||||
|
when {
|
||||||
|
code == clearCode -> {
|
||||||
|
if (previousCode < 0 && codeCount > 1) return false
|
||||||
|
codeSize = minimumCodeSize + 1
|
||||||
|
nextCode = endOfInformationCode + 1
|
||||||
|
previousCode = -1
|
||||||
|
}
|
||||||
|
code == endOfInformationCode -> {
|
||||||
|
return decodedPixels == expectedPixels && bitReader.hasOnlyPaddingBits()
|
||||||
|
}
|
||||||
|
previousCode < 0 -> {
|
||||||
|
if (code >= clearCode) return false
|
||||||
|
decodedPixels++
|
||||||
|
if (decodedPixels > expectedPixels) return false
|
||||||
|
previousCode = code
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val decodedLength = when {
|
||||||
|
code < clearCode -> 1
|
||||||
|
code < nextCode && codeLengths[code] > 0 -> codeLengths[code]
|
||||||
|
code == nextCode && nextCode < MAX_GIF_LZW_TABLE_SIZE -> codeLengths[previousCode] + 1
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
decodedPixels += decodedLength
|
||||||
|
if (decodedPixels > expectedPixels) return false
|
||||||
|
|
||||||
|
if (nextCode < MAX_GIF_LZW_TABLE_SIZE) {
|
||||||
|
codeLengths[nextCode++] = codeLengths[previousCode] + 1
|
||||||
|
if (nextCode == (1 shl codeSize) && codeSize < MAX_GIF_LZW_BITS) codeSize++
|
||||||
|
}
|
||||||
|
previousCode = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validatedFrameCount(reader: javax.imageio.ImageReader, format: String, bytes: ByteArray): Int? {
|
||||||
|
if (format != "gif") {
|
||||||
|
return 1.takeIf { hasValidDimensions(width = reader.getWidth(0), height = reader.getHeight(0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET) return null
|
||||||
|
val logicalWidth = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_WIDTH_OFFSET)
|
||||||
|
val logicalHeight = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_HEIGHT_OFFSET)
|
||||||
|
if (!hasValidDimensions(width = logicalWidth, height = logicalHeight)) return null
|
||||||
|
|
||||||
|
val frameCount = reader.getNumImages(true)
|
||||||
|
if (frameCount < 1) return null
|
||||||
|
val hasInvalidFrame = (0 until frameCount).any { index ->
|
||||||
|
!hasValidDimensions(width = reader.getWidth(index), height = reader.getHeight(index))
|
||||||
|
}
|
||||||
|
if (hasInvalidFrame) return null
|
||||||
|
return frameCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasValidDimensions(width: Int, height: Int): Boolean {
|
||||||
|
return width in 1..MAX_IMAGE_DIMENSION &&
|
||||||
|
height in 1..MAX_IMAGE_DIMENSION &&
|
||||||
|
width.toLong() * height <= MAX_IMAGE_PIXELS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLittleEndianUnsignedShort(bytes: ByteArray, offset: Int): Int {
|
||||||
|
return (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBigEndianInt(bytes: ByteArray, offset: Int): Int {
|
||||||
|
return ((bytes[offset].toInt() and 0xFF) shl 24) or
|
||||||
|
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
|
||||||
|
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
|
||||||
|
(bytes[offset + 3].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeFormat(format: String): String? {
|
||||||
|
return when (format.lowercase()) {
|
||||||
|
"jpeg" -> "jpg"
|
||||||
|
"png", "jpg", "gif" -> format.lowercase()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidImage(errorProperty: String): SodaException {
|
||||||
|
return SodaException(
|
||||||
|
messageKey = "admin.chat.character.image_format_invalid",
|
||||||
|
errorProperty = errorProperty.ifBlank { "image" },
|
||||||
|
httpStatus = HttpStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_IMAGE_BYTES = 10L * 1024L * 1024L
|
||||||
|
private const val MAX_IMAGE_METADATA_BYTES = 1024 * 1024
|
||||||
|
private const val MAX_IMAGE_DIMENSION = 20_000
|
||||||
|
private const val MAX_IMAGE_PIXELS = 40_000_000L
|
||||||
|
private const val MAX_PNG_CHUNKS = 4096
|
||||||
|
private const val MAX_GIF_FRAMES = 500
|
||||||
|
private const val MAX_GIF_EXTENSIONS = 1024
|
||||||
|
private const val MAX_GIF_EXTENSION_SUB_BLOCKS = 64
|
||||||
|
private const val MIN_GIF_LZW_CODE_SIZE = 2
|
||||||
|
private const val MAX_GIF_LZW_CODE_SIZE = 8
|
||||||
|
private const val MAX_GIF_LZW_BITS = 12
|
||||||
|
private const val MAX_GIF_LZW_TABLE_SIZE = 1 shl MAX_GIF_LZW_BITS
|
||||||
|
private const val PNG_CHUNK_HEADER_BYTES = 8
|
||||||
|
private const val PNG_CHUNK_CRC_BYTES = 4
|
||||||
|
private const val GIF_LOGICAL_SCREEN_WIDTH_OFFSET = 6
|
||||||
|
private const val GIF_LOGICAL_SCREEN_HEIGHT_OFFSET = 8
|
||||||
|
private const val GIF_LOGICAL_SCREEN_END_OFFSET = 10
|
||||||
|
private const val GIF_IMAGE_DESCRIPTOR_BYTES = 9
|
||||||
|
private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class GifSubBlockScan(
|
||||||
|
val nextOffset: Int,
|
||||||
|
val payloadBytes: Long,
|
||||||
|
val payload: ByteArray?
|
||||||
|
)
|
||||||
|
|
||||||
|
private class GifLzwBitReader(
|
||||||
|
private val data: ByteArray
|
||||||
|
) {
|
||||||
|
private var offset = 0
|
||||||
|
private var bitBuffer = 0L
|
||||||
|
private var bufferedBits = 0
|
||||||
|
|
||||||
|
fun read(bitCount: Int): Int? {
|
||||||
|
while (bufferedBits < bitCount) {
|
||||||
|
if (offset >= data.size) return null
|
||||||
|
bitBuffer = bitBuffer or ((data[offset++].toInt() and 0xFF).toLong() shl bufferedBits)
|
||||||
|
bufferedBits += 8
|
||||||
|
}
|
||||||
|
|
||||||
|
val code = (bitBuffer and ((1L shl bitCount) - 1L)).toInt()
|
||||||
|
bitBuffer = bitBuffer ushr bitCount
|
||||||
|
bufferedBits -= bitCount
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasOnlyPaddingBits(): Boolean {
|
||||||
|
return offset == data.size && bufferedBits < 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AdminValidatedImage(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val contentType: String,
|
||||||
|
val extension: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AdminJsonRequestParser(private val objectMapper: ObjectMapper) {
|
||||||
|
fun parseRequiredObject(rawJson: String, requiredKeys: Set<String>): ObjectNode {
|
||||||
|
val node: JsonNode = try {
|
||||||
|
objectMapper.reader()
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||||
|
.readTree(rawJson)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException(
|
||||||
|
messageKey = "common.error.invalid_request",
|
||||||
|
errorProperty = "request",
|
||||||
|
httpStatus = HttpStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseRequiredObject(node = node, requiredKeys = requiredKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseRequiredObject(node: JsonNode, requiredKeys: Set<String>): ObjectNode {
|
||||||
|
if (!node.isObject) {
|
||||||
|
throw SodaException(
|
||||||
|
messageKey = "common.error.invalid_request",
|
||||||
|
errorProperty = "request",
|
||||||
|
httpStatus = HttpStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val objectNode = node as ObjectNode
|
||||||
|
requiredKeys.firstOrNull { key -> !objectNode.has(key) }?.let { key ->
|
||||||
|
throw SodaException(
|
||||||
|
messageKey = "common.error.invalid_request",
|
||||||
|
errorProperty = key,
|
||||||
|
httpStatus = HttpStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectNode
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonMappingException
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.core.Ordered
|
||||||
|
import org.springframework.core.annotation.Order
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||||
|
import org.springframework.security.access.AccessDeniedException
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
|
||||||
|
import org.springframework.web.multipart.MultipartException
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||||
|
|
||||||
|
@RestControllerAdvice(basePackages = ["kr.co.vividnext.sodalive.v2.admin.aicharacter"])
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
class AiCharacterAdminExceptionHandler(
|
||||||
|
private val langContext: LangContext,
|
||||||
|
private val messageSource: SodaMessageSource
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
@ExceptionHandler(SodaException::class)
|
||||||
|
fun handleSodaException(e: SodaException): ResponseEntity<ApiResponse<Any>> {
|
||||||
|
val message = resolveMessage(e, langContext.lang)
|
||||||
|
logger.warn(
|
||||||
|
"AI character admin API error status={} errorProperty={} error={}",
|
||||||
|
e.httpStatus ?: HttpStatus.BAD_REQUEST,
|
||||||
|
e.errorProperty,
|
||||||
|
e.javaClass.simpleName
|
||||||
|
)
|
||||||
|
return ResponseEntity
|
||||||
|
.status(e.httpStatus ?: HttpStatus.BAD_REQUEST)
|
||||||
|
.body(ApiResponse.error(message = message, errorProperty = e.errorProperty))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AccessDeniedException::class)
|
||||||
|
fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity<ApiResponse<Any>> {
|
||||||
|
val message = messageSource.getMessage("common.error.access_denied", langContext.lang) ?: "You do not have permission."
|
||||||
|
logger.warn("AI character admin API access denied error={}", e.javaClass.simpleName)
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(message = message))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(
|
||||||
|
HttpMessageNotReadableException::class,
|
||||||
|
MethodArgumentTypeMismatchException::class,
|
||||||
|
MissingServletRequestParameterException::class,
|
||||||
|
MissingServletRequestPartException::class,
|
||||||
|
MultipartException::class
|
||||||
|
)
|
||||||
|
fun handleBadRequestException(e: Exception): ResponseEntity<ApiResponse<Any>> {
|
||||||
|
val message = messageSource.getMessage("common.error.invalid_request", langContext.lang) ?: "Invalid request."
|
||||||
|
val errorProperty = resolveBadRequestErrorProperty(e)
|
||||||
|
logger.warn("AI character admin bad request error={} errorProperty={}", e.javaClass.simpleName, errorProperty)
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body(ApiResponse.error(message = message, errorProperty = errorProperty))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception::class)
|
||||||
|
fun handleException(e: Exception): ResponseEntity<ApiResponse<Any>> {
|
||||||
|
val message = messageSource.getMessage("common.error.unknown", langContext.lang) ?: DEFAULT_UNKNOWN_MESSAGE
|
||||||
|
logger.error("AI character admin API error error={}", e.javaClass.simpleName)
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error(message = message))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBadRequestErrorProperty(e: Exception): String? {
|
||||||
|
return when (e) {
|
||||||
|
is MethodArgumentTypeMismatchException -> e.name
|
||||||
|
is MissingServletRequestParameterException -> e.parameterName
|
||||||
|
is MissingServletRequestPartException -> e.requestPartName
|
||||||
|
is HttpMessageNotReadableException -> resolveUnreadableMessageErrorProperty(e)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveUnreadableMessageErrorProperty(e: HttpMessageNotReadableException): String {
|
||||||
|
val mappingException = generateSequence(e.cause) { it.cause }
|
||||||
|
.filterIsInstance<JsonMappingException>()
|
||||||
|
.firstOrNull()
|
||||||
|
return mappingException?.path
|
||||||
|
?.asSequence()
|
||||||
|
?.mapNotNull { it.fieldName?.takeIf(String::isNotBlank) }
|
||||||
|
?.firstOrNull()
|
||||||
|
?: "request"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveMessage(e: SodaException, lang: Lang): String {
|
||||||
|
return e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, lang) }
|
||||||
|
?: e.message?.takeIf { it.isNotBlank() }.orEmpty().ifBlank { null }
|
||||||
|
?: messageSource.getMessage("common.error.unknown", lang)
|
||||||
|
?: DEFAULT_UNKNOWN_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_UNKNOWN_MESSAGE = "An unknown error occurred."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageRequest
|
||||||
|
|
||||||
|
object AdminPagePolicy {
|
||||||
|
fun normalize(page: Int?, size: Int?): AdminPageRequest {
|
||||||
|
val normalizedPage = page?.coerceAtLeast(0) ?: 0
|
||||||
|
val normalizedSize = when {
|
||||||
|
size == null -> DEFAULT_SIZE
|
||||||
|
size < MIN_SIZE -> DEFAULT_SIZE
|
||||||
|
size > MAX_SIZE -> MAX_SIZE
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdminPageRequest(
|
||||||
|
page = normalizedPage,
|
||||||
|
size = normalizedSize,
|
||||||
|
offset = normalizedPage.toLong() * normalizedSize,
|
||||||
|
limit = normalizedSize.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_SIZE = 20
|
||||||
|
private const val MIN_SIZE = 1
|
||||||
|
private const val MAX_SIZE = 50
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AiCharacterAdminAuditLogger {
|
||||||
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
fun logSuccess(context: AiCharacterAdminAuditContext) {
|
||||||
|
logger.info(
|
||||||
|
"aiCharacterAdminAudit result={} adminMemberId={} characterId={} " +
|
||||||
|
"creatorMemberId={} action={} resourceType={} resourceId={}",
|
||||||
|
AiCharacterAdminAuditResult.SUCCESS,
|
||||||
|
context.adminMemberId,
|
||||||
|
context.characterId,
|
||||||
|
context.creatorMemberId,
|
||||||
|
context.action,
|
||||||
|
context.resourceType,
|
||||||
|
context.resourceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logFailure(context: AiCharacterAdminAuditContext, exception: Exception) {
|
||||||
|
logger.warn(
|
||||||
|
"aiCharacterAdminAudit result={} adminMemberId={} characterId={} " +
|
||||||
|
"creatorMemberId={} action={} resourceType={} resourceId={} error={}",
|
||||||
|
AiCharacterAdminAuditResult.FAILURE,
|
||||||
|
context.adminMemberId,
|
||||||
|
context.characterId,
|
||||||
|
context.creatorMemberId,
|
||||||
|
context.action,
|
||||||
|
context.resourceType,
|
||||||
|
context.resourceId,
|
||||||
|
exception.javaClass.simpleName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiCharacterAdminAuditContext private constructor(
|
||||||
|
val adminMemberId: Long,
|
||||||
|
val characterId: Long?,
|
||||||
|
val creatorMemberId: Long?,
|
||||||
|
val action: AiCharacterAdminAuditAction,
|
||||||
|
val resourceType: AiCharacterAdminAuditResourceType,
|
||||||
|
val resourceId: Long?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun globalOriginalWork(
|
||||||
|
adminMemberId: Long,
|
||||||
|
action: AiCharacterAdminAuditAction,
|
||||||
|
resourceType: AiCharacterAdminAuditResourceType,
|
||||||
|
resourceId: Long?
|
||||||
|
): AiCharacterAdminAuditContext {
|
||||||
|
require(resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK)
|
||||||
|
require(
|
||||||
|
action == AiCharacterAdminAuditAction.CREATE ||
|
||||||
|
action == AiCharacterAdminAuditAction.UPDATE ||
|
||||||
|
action == AiCharacterAdminAuditAction.DELETE
|
||||||
|
)
|
||||||
|
return AiCharacterAdminAuditContext(
|
||||||
|
adminMemberId = adminMemberId,
|
||||||
|
characterId = null,
|
||||||
|
creatorMemberId = null,
|
||||||
|
action = action,
|
||||||
|
resourceType = resourceType,
|
||||||
|
resourceId = resourceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun characterScoped(
|
||||||
|
adminMemberId: Long,
|
||||||
|
characterId: Long,
|
||||||
|
creatorMemberId: Long?,
|
||||||
|
action: AiCharacterAdminAuditAction,
|
||||||
|
resourceType: AiCharacterAdminAuditResourceType,
|
||||||
|
resourceId: Long?
|
||||||
|
): AiCharacterAdminAuditContext {
|
||||||
|
val isAssignmentAction =
|
||||||
|
action == AiCharacterAdminAuditAction.ASSIGN || action == AiCharacterAdminAuditAction.UNASSIGN
|
||||||
|
val isOriginalWorkCharacter = resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER
|
||||||
|
require(resourceType != AiCharacterAdminAuditResourceType.ORIGINAL_WORK)
|
||||||
|
require(isAssignmentAction == isOriginalWorkCharacter)
|
||||||
|
require(action == AiCharacterAdminAuditAction.UNASSIGN || creatorMemberId != null)
|
||||||
|
return AiCharacterAdminAuditContext(
|
||||||
|
adminMemberId = adminMemberId,
|
||||||
|
characterId = characterId,
|
||||||
|
creatorMemberId = creatorMemberId,
|
||||||
|
action = action,
|
||||||
|
resourceType = resourceType,
|
||||||
|
resourceId = resourceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AiCharacterAdminAuditAction {
|
||||||
|
CREATE,
|
||||||
|
UPDATE,
|
||||||
|
DELETE,
|
||||||
|
ASSIGN,
|
||||||
|
UNASSIGN,
|
||||||
|
PIN,
|
||||||
|
READ
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AiCharacterAdminAuditResourceType {
|
||||||
|
CHARACTER,
|
||||||
|
ORIGINAL_WORK,
|
||||||
|
ORIGINAL_WORK_CHARACTER,
|
||||||
|
CONTENT,
|
||||||
|
CONTENT_COMMENT,
|
||||||
|
CONTENT_CATEGORY,
|
||||||
|
SERIES,
|
||||||
|
COMMUNITY_POST,
|
||||||
|
COMMUNITY_COMMENT,
|
||||||
|
FAN_TALK,
|
||||||
|
FAN_TALK_REPLY,
|
||||||
|
NOTICE,
|
||||||
|
CHANNEL_NOTICE,
|
||||||
|
CREATOR_TAG,
|
||||||
|
CHANNEL_PROFILE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AiCharacterAdminAuditResult {
|
||||||
|
SUCCESS,
|
||||||
|
FAILURE
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.dto
|
||||||
|
|
||||||
|
data class AdminPageRequest(
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val offset: Long,
|
||||||
|
val limit: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminPageResponse<T>(
|
||||||
|
val totalCount: Long,
|
||||||
|
val items: List<T>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun <T> of(totalCount: Long, content: List<T>, page: Int, size: Int): AdminPageResponse<T> {
|
||||||
|
return AdminPageResponse(
|
||||||
|
totalCount = totalCount,
|
||||||
|
items = content,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
hasNext = ((page.toLong() + 1L) * size.toLong()) < totalCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AdminMutationResponse(
|
||||||
|
val id: Long,
|
||||||
|
val isActive: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminCommentUpdateRequest(
|
||||||
|
val content: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||||
|
import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
import javax.persistence.NoResultException
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class DefaultAiCharacterPersistenceAdapter(
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) : AiCharacterPersistencePort {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? {
|
||||||
|
val character = findCharacter(characterId) ?: return null
|
||||||
|
val creatorMember = character.creatorMember ?: return null
|
||||||
|
if (creatorMember.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return AiCharacterAdminTarget(
|
||||||
|
characterId = character.id!!,
|
||||||
|
creatorMemberId = creatorMember.id!!,
|
||||||
|
characterIsActive = character.isActive,
|
||||||
|
creatorMemberIsActive = creatorMember.isActive,
|
||||||
|
creatorRole = creatorMember.role,
|
||||||
|
memberKind = creatorMember.memberKind
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findCharacter(characterId: Long): ChatCharacter? {
|
||||||
|
return try {
|
||||||
|
entityManager.createQuery(
|
||||||
|
"""
|
||||||
|
select c
|
||||||
|
from ChatCharacter c
|
||||||
|
left join fetch c.creatorMember
|
||||||
|
where c.id = :characterId
|
||||||
|
""".trimIndent(),
|
||||||
|
ChatCharacter::class.java
|
||||||
|
).setParameter("characterId", characterId)
|
||||||
|
.singleResult
|
||||||
|
} catch (e: NoResultException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.aicharacter.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||||
|
import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AiCharacterAdminTargetResolver(
|
||||||
|
private val persistencePort: AiCharacterPersistencePort
|
||||||
|
) {
|
||||||
|
fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
|
||||||
|
val target = resolveExistingTarget(characterId)
|
||||||
|
if (!target.characterIsActive || !target.creatorMemberIsActive) {
|
||||||
|
throw SodaException(
|
||||||
|
messageKey = "common.error.invalid_request",
|
||||||
|
errorProperty = "characterId",
|
||||||
|
httpStatus = HttpStatus.CONFLICT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveExistingTarget(characterId: Long): AiCharacterAdminTarget {
|
||||||
|
val target = persistencePort.findAdminTarget(characterId) ?: throwNotFound()
|
||||||
|
if (target.creatorRole != MemberRole.CREATOR || target.memberKind != MemberKind.AI_CHARACTER) {
|
||||||
|
throwNotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun throwNotFound(): Nothing {
|
||||||
|
throw SodaException(
|
||||||
|
messageKey = "admin.chat.character.not_found",
|
||||||
|
errorProperty = "characterId",
|
||||||
|
httpStatus = HttpStatus.NOT_FOUND
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.aicharacter.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
|
||||||
|
data class AiCharacterAdminTarget(
|
||||||
|
val characterId: Long,
|
||||||
|
val creatorMemberId: Long,
|
||||||
|
val characterIsActive: Boolean,
|
||||||
|
val creatorMemberIsActive: Boolean,
|
||||||
|
val creatorRole: MemberRole,
|
||||||
|
val memberKind: MemberKind
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.aicharacter.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||||
|
|
||||||
|
interface AiCharacterPersistencePort {
|
||||||
|
fun findAdminTarget(characterId: Long): AiCharacterAdminTarget?
|
||||||
|
}
|
||||||
@@ -75,7 +75,10 @@ class HomeRecommendationFacade(
|
|||||||
includeAdultContents = includeAdult
|
includeAdultContents = includeAdult
|
||||||
)
|
)
|
||||||
.map { it.toItem() },
|
.map { it.toItem() },
|
||||||
aiCharacters = queryService.findAiCharacterRecommendations(limit = HOME_AI_CHARACTER_LIMIT).map { it.toItem() },
|
aiCharacters = queryService.findAiCharacterRecommendations(
|
||||||
|
limit = HOME_AI_CHARACTER_LIMIT,
|
||||||
|
backfillRandom = true
|
||||||
|
).map { it.toItem() },
|
||||||
genreCreators = queryService.findGenreCreatorRecommendations(
|
genreCreators = queryService.findGenreCreatorRecommendations(
|
||||||
memberId = member?.id,
|
memberId = member?.id,
|
||||||
includeAdultGenres = includeAdult,
|
includeAdultGenres = includeAdult,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.common.application
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AfterCommitExecutor {
|
||||||
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
fun executeAfterCommit(callback: () -> Unit) {
|
||||||
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
executeCallback(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(
|
||||||
|
object : TransactionSynchronization {
|
||||||
|
override fun afterCommit() {
|
||||||
|
executeCallback(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeCallback(callback: () -> Unit) {
|
||||||
|
try {
|
||||||
|
callback()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("afterCommit callback failed error={}", e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -228,8 +228,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
union all
|
union all
|
||||||
select lr.member_id as creator_id, lr.begin_date_time as debut_at
|
select lr.member_id as creator_id, lr.begin_date_time as debut_at
|
||||||
from live_room lr
|
from live_room lr
|
||||||
where lr.is_active = true
|
where lr.channel_name is not null
|
||||||
and lr.channel_name is not null
|
|
||||||
and lr.channel_name <> ''
|
and lr.channel_name <> ''
|
||||||
and lr.begin_date_time <= :now
|
and lr.begin_date_time <= :now
|
||||||
and (:includeAdultContents = true or lr.is_adult = false)
|
and (:includeAdultContents = true or lr.is_adult = false)
|
||||||
@@ -776,6 +775,29 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findRandomAiCharacterRecommendationIds(
|
||||||
|
excludeCharacterIds: List<Long>,
|
||||||
|
limit: Int
|
||||||
|
): List<Long> {
|
||||||
|
val creatorMember = QMember("creatorMember")
|
||||||
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(chatCharacter.id)
|
||||||
|
.from(chatCharacter)
|
||||||
|
.join(chatCharacter.creatorMember, creatorMember)
|
||||||
|
.where(
|
||||||
|
chatCharacter.isActive.isTrue,
|
||||||
|
excludeAiCharacterCondition(excludeCharacterIds),
|
||||||
|
creatorMember.isActive.isTrue,
|
||||||
|
creatorMember.role.eq(MemberRole.CREATOR),
|
||||||
|
creatorMember.memberKind.eq(MemberKind.AI_CHARACTER)
|
||||||
|
)
|
||||||
|
.orderBy(randomTieBreaker.asc())
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findCheerCreatorRecommendationDetails(
|
override fun findCheerCreatorRecommendationDetails(
|
||||||
creatorIds: List<Long>,
|
creatorIds: List<Long>,
|
||||||
memberId: Long?
|
memberId: Long?
|
||||||
@@ -1236,6 +1258,10 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
|
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun excludeAiCharacterCondition(excludeCharacterIds: List<Long>): BooleanExpression? {
|
||||||
|
return if (excludeCharacterIds.isEmpty()) null else chatCharacter.id.notIn(excludeCharacterIds)
|
||||||
|
}
|
||||||
|
|
||||||
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||||
if (memberId == null) return null
|
if (memberId == null) return null
|
||||||
val blockMember = QBlockMember("recommendationBlockMember")
|
val blockMember = QBlockMember("recommendationBlockMember")
|
||||||
|
|||||||
@@ -78,13 +78,23 @@ class HomeRecommendationQueryService(
|
|||||||
|
|
||||||
fun findAiCharacterRecommendations(
|
fun findAiCharacterRecommendations(
|
||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Int = DEFAULT_AI_CHARACTER_LIMIT
|
limit: Int = DEFAULT_AI_CHARACTER_LIMIT,
|
||||||
|
backfillRandom: Boolean = false
|
||||||
): List<HomeAiCharacterRecommendationRecord> {
|
): List<HomeAiCharacterRecommendationRecord> {
|
||||||
val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit)
|
val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit)
|
||||||
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
|
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
|
||||||
.associateBy { it.characterId }
|
.associateBy { it.characterId }
|
||||||
|
val snapshotDetails = snapshots.mapNotNull { detailsById[it.targetId] }
|
||||||
|
|
||||||
return snapshots.mapNotNull { detailsById[it.targetId] }
|
if (!backfillRandom || snapshots.isEmpty() || snapshotDetails.size >= limit) return snapshotDetails
|
||||||
|
|
||||||
|
val randomIds = queryPort.findRandomAiCharacterRecommendationIds(
|
||||||
|
excludeCharacterIds = snapshots.map { it.targetId },
|
||||||
|
limit = limit - snapshotDetails.size
|
||||||
|
)
|
||||||
|
val randomDetailsById = queryPort.findAiCharacterRecommendationDetails(randomIds).associateBy { it.characterId }
|
||||||
|
val randomDetails = randomIds.mapNotNull { randomDetailsById[it] }
|
||||||
|
return (snapshotDetails + randomDetails).distinctBy { it.characterId }.take(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findAiCharacterSnapshotsWithFallback(
|
private fun findAiCharacterSnapshotsWithFallback(
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ interface HomeRecommendationQueryPort {
|
|||||||
|
|
||||||
fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord>
|
fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord>
|
||||||
|
|
||||||
|
fun findRandomAiCharacterRecommendationIds(
|
||||||
|
excludeCharacterIds: List<Long>,
|
||||||
|
limit: Int
|
||||||
|
): List<Long>
|
||||||
|
|
||||||
fun findCheerCreatorRecommendationDetails(
|
fun findCheerCreatorRecommendationDetails(
|
||||||
creatorIds: List<Long>,
|
creatorIds: List<Long>,
|
||||||
memberId: Long? = null
|
memberId: Long? = null
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character
|
package kr.co.vividnext.sodalive.admin.chat.character
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3Client
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.BackgroundResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.MemoryResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkBriefResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.PersonalityResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.RelationshipResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.data.domain.PageImpl
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
class AdminChatCharacterControllerTest {
|
class AdminChatCharacterControllerTest {
|
||||||
private val controller = AdminChatCharacterController(
|
private val controller = AdminChatCharacterController(
|
||||||
@@ -35,6 +75,523 @@ class AdminChatCharacterControllerTest {
|
|||||||
return method.invoke(controller, region, gender) as String
|
return method.invoke(controller, region, gender) as String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterBaseContract() {
|
||||||
|
val classMapping = AdminChatCharacterController::class.java.getAnnotation(RequestMapping::class.java)
|
||||||
|
val preAuthorize = AdminChatCharacterController::class.java.getAnnotation(PreAuthorize::class.java)
|
||||||
|
|
||||||
|
assertEquals("/admin/chat/character", classMapping.value.single())
|
||||||
|
assertEquals("hasRole('ADMIN')", preAuthorize.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterRoutes() {
|
||||||
|
assertEquals(
|
||||||
|
"/list",
|
||||||
|
method("getCharacterList", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/search",
|
||||||
|
method("searchCharacters", String::class.java, Int::class.java, Int::class.java)
|
||||||
|
.getAnnotation(GetMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/{characterId}",
|
||||||
|
method("getCharacterDetail", Long::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/register",
|
||||||
|
method("registerCharacter", MultipartFile::class.java, String::class.java)
|
||||||
|
.getAnnotation(PostMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/update",
|
||||||
|
method("updateCharacter", MultipartFile::class.java, String::class.java)
|
||||||
|
.getAnnotation(PutMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterMultipartRequestParts() {
|
||||||
|
val register = method("registerCharacter", MultipartFile::class.java, String::class.java)
|
||||||
|
val update = method("updateCharacter", MultipartFile::class.java, String::class.java)
|
||||||
|
|
||||||
|
assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||||
|
assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||||
|
assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||||
|
assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required)
|
||||||
|
assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterUpdateRequestFields() {
|
||||||
|
val request = ChatCharacterUpdateRequest(
|
||||||
|
id = 1L,
|
||||||
|
name = "character",
|
||||||
|
systemPrompt = "prompt",
|
||||||
|
description = "description",
|
||||||
|
age = null,
|
||||||
|
gender = null,
|
||||||
|
mbti = null,
|
||||||
|
speechPattern = null,
|
||||||
|
speechStyle = null,
|
||||||
|
appearance = null,
|
||||||
|
originalTitle = "title",
|
||||||
|
originalLink = null,
|
||||||
|
originalWorkId = null,
|
||||||
|
characterType = null,
|
||||||
|
isActive = null,
|
||||||
|
tags = emptyList(),
|
||||||
|
hobbies = emptyList(),
|
||||||
|
values = emptyList(),
|
||||||
|
goals = emptyList(),
|
||||||
|
relationships = emptyList(),
|
||||||
|
personalities = emptyList(),
|
||||||
|
backgrounds = emptyList(),
|
||||||
|
memories = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1L, request.id)
|
||||||
|
assertEquals("character", request.name)
|
||||||
|
assertEquals("prompt", request.systemPrompt)
|
||||||
|
assertEquals("description", request.description)
|
||||||
|
assertEquals("title", request.originalTitle)
|
||||||
|
assertEquals(emptyList<String>(), request.tags)
|
||||||
|
assertEquals(emptyList<String>(), request.memories)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterListResponseSurface() {
|
||||||
|
val adminService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||||
|
Mockito.`when`(adminService.createDefaultPageRequest(0, 20)).thenReturn(PageRequest.of(0, 20))
|
||||||
|
Mockito.`when`(adminService.getActiveChatCharacters(PageRequest.of(0, 20), "https://cdn.example.com"))
|
||||||
|
.thenReturn(
|
||||||
|
ChatCharacterListPageResponse(
|
||||||
|
totalCount = 1,
|
||||||
|
content = listOf(characterListResponse())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/chat/character/list").param("page", "0").param("size", "20"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].name").value("character"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].description").value("description"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].gender").value("여성"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].age").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].mbti").value("INTJ"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].speechStyle").value("calm"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].speechPattern").value("polite"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].region").value("KR"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].tags[0]").value("tag"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 캐릭터 검색은 query와 비기본 pagination을 service에 전달하고 기존 응답을 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminCharacterSearchRequestAndResponseSurface() {
|
||||||
|
val adminService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||||
|
val pageable = PageRequest.of(2, 7)
|
||||||
|
Mockito.`when`(adminService.createDefaultPageRequest(2, 7)).thenReturn(pageable)
|
||||||
|
Mockito.`when`(adminService.searchCharacters("character", pageable, "https://cdn.example.com"))
|
||||||
|
.thenReturn(PageImpl(listOf(characterListResponse()), pageable, 15))
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/admin/chat/character/search")
|
||||||
|
.param("searchTerm", "character")
|
||||||
|
.param("page", "2")
|
||||||
|
.param("size", "7")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(15))
|
||||||
|
.andExpect(jsonPath("$.data.content").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].name").value("character"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].description").value("description"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].gender").value("여성"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].age").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].mbti").value("INTJ"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].speechStyle").value("calm"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].speechPattern").value("polite"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].region").value("KR"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].tags[0]").value("tag"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01"))
|
||||||
|
|
||||||
|
Mockito.verify(adminService).createDefaultPageRequest(2, 7)
|
||||||
|
Mockito.verify(adminService).searchCharacters("character", pageable, "https://cdn.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterDetailResponseSurface() {
|
||||||
|
val adminService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||||
|
Mockito.`when`(adminService.getChatCharacterDetail(1L, "https://cdn.example.com"))
|
||||||
|
.thenReturn(
|
||||||
|
ChatCharacterDetailResponse(
|
||||||
|
id = 1L,
|
||||||
|
characterUUID = "uuid",
|
||||||
|
name = "character",
|
||||||
|
imageUrl = "https://cdn.example.com/characters/1.png",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt",
|
||||||
|
characterType = "Character",
|
||||||
|
age = 20,
|
||||||
|
gender = "여성",
|
||||||
|
mbti = "INTJ",
|
||||||
|
speechPattern = "polite",
|
||||||
|
speechStyle = "calm",
|
||||||
|
appearance = "appearance",
|
||||||
|
region = "KR",
|
||||||
|
isActive = true,
|
||||||
|
tags = listOf("tag"),
|
||||||
|
hobbies = listOf("hobby"),
|
||||||
|
values = listOf("value"),
|
||||||
|
goals = listOf("goal"),
|
||||||
|
relationships = listOf(
|
||||||
|
RelationshipResponse(
|
||||||
|
personName = "person",
|
||||||
|
relationshipName = "friend",
|
||||||
|
description = "relationship description",
|
||||||
|
importance = 5,
|
||||||
|
relationshipType = "ally",
|
||||||
|
currentStatus = "active"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
personalities = listOf(PersonalityResponse(trait = "kind", description = "personality description")),
|
||||||
|
backgrounds = listOf(BackgroundResponse(topic = "past", description = "background description")),
|
||||||
|
memories = listOf(MemoryResponse(title = "memory", content = "memory content", emotion = "happy")),
|
||||||
|
originalWork = OriginalWorkBriefResponse(
|
||||||
|
id = 10L,
|
||||||
|
imageUrl = "https://cdn.example.com/originals/10.png",
|
||||||
|
title = "original title"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/chat/character/1"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.id").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.characterUUID").value("uuid"))
|
||||||
|
.andExpect(jsonPath("$.data.name").value("character"))
|
||||||
|
.andExpect(jsonPath("$.data.imageUrl").value("https://cdn.example.com/characters/1.png"))
|
||||||
|
.andExpect(jsonPath("$.data.description").value("description"))
|
||||||
|
.andExpect(jsonPath("$.data.systemPrompt").value("prompt"))
|
||||||
|
.andExpect(jsonPath("$.data.characterType").value("Character"))
|
||||||
|
.andExpect(jsonPath("$.data.age").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.gender").value("여성"))
|
||||||
|
.andExpect(jsonPath("$.data.mbti").value("INTJ"))
|
||||||
|
.andExpect(jsonPath("$.data.speechPattern").value("polite"))
|
||||||
|
.andExpect(jsonPath("$.data.speechStyle").value("calm"))
|
||||||
|
.andExpect(jsonPath("$.data.appearance").value("appearance"))
|
||||||
|
.andExpect(jsonPath("$.data.region").value("KR"))
|
||||||
|
.andExpect(jsonPath("$.data.isActive").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.tags[0]").value("tag"))
|
||||||
|
.andExpect(jsonPath("$.data.hobbies[0]").value("hobby"))
|
||||||
|
.andExpect(jsonPath("$.data.values[0]").value("value"))
|
||||||
|
.andExpect(jsonPath("$.data.goals[0]").value("goal"))
|
||||||
|
.andExpect(jsonPath("$.data.relationships[0].personName").value("person"))
|
||||||
|
.andExpect(jsonPath("$.data.relationships[0].relationshipName").value("friend"))
|
||||||
|
.andExpect(jsonPath("$.data.relationships[0].description").value("relationship description"))
|
||||||
|
.andExpect(jsonPath("$.data.relationships[0].importance").value(5))
|
||||||
|
.andExpect(jsonPath("$.data.relationships[0].relationshipType").value("ally"))
|
||||||
|
.andExpect(jsonPath("$.data.relationships[0].currentStatus").value("active"))
|
||||||
|
.andExpect(jsonPath("$.data.personalities[0].trait").value("kind"))
|
||||||
|
.andExpect(jsonPath("$.data.personalities[0].description").value("personality description"))
|
||||||
|
.andExpect(jsonPath("$.data.backgrounds[0].topic").value("past"))
|
||||||
|
.andExpect(jsonPath("$.data.backgrounds[0].description").value("background description"))
|
||||||
|
.andExpect(jsonPath("$.data.memories[0].title").value("memory"))
|
||||||
|
.andExpect(jsonPath("$.data.memories[0].content").value("memory content"))
|
||||||
|
.andExpect(jsonPath("$.data.memories[0].emotion").value("happy"))
|
||||||
|
.andExpect(jsonPath("$.data.originalWork.id").value(10))
|
||||||
|
.andExpect(jsonPath("$.data.originalWork.imageUrl").value("https://cdn.example.com/originals/10.png"))
|
||||||
|
.andExpect(jsonPath("$.data.originalWork.title").value("original title"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterRegisterResponseSurface() {
|
||||||
|
val outboundRequests = CopyOnWriteArrayList<RecordedHttpRequest>()
|
||||||
|
val server = externalCharacterApiServer(outboundRequests)
|
||||||
|
try {
|
||||||
|
val service = chatCharacterServiceFake()
|
||||||
|
val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
controllerForMutation(server, service, originalWorkService)
|
||||||
|
).build()
|
||||||
|
val request = """
|
||||||
|
{
|
||||||
|
"name":"character",
|
||||||
|
"systemPrompt":"prompt",
|
||||||
|
"description":"description",
|
||||||
|
"age":"20",
|
||||||
|
"gender":"여성",
|
||||||
|
"mbti":"INTJ",
|
||||||
|
"speechPattern":"polite",
|
||||||
|
"speechStyle":"calm",
|
||||||
|
"appearance":"appearance",
|
||||||
|
"region":"KR",
|
||||||
|
"originalTitle":"original title",
|
||||||
|
"originalLink":"https://original.test",
|
||||||
|
"originalWorkId":10,
|
||||||
|
"characterType":"Character",
|
||||||
|
"tags":["tag"],
|
||||||
|
"hobbies":["hobby"],
|
||||||
|
"values":["value"],
|
||||||
|
"goals":["goal"],
|
||||||
|
"relationships":[{
|
||||||
|
"personName":"person",
|
||||||
|
"relationshipName":"friend",
|
||||||
|
"description":"relationship description",
|
||||||
|
"importance":5,
|
||||||
|
"relationshipType":"ally",
|
||||||
|
"currentStatus":"active"
|
||||||
|
}],
|
||||||
|
"personalities":[{"trait":"kind","description":"personality description"}],
|
||||||
|
"backgrounds":[{"topic":"past","description":"background description"}],
|
||||||
|
"memories":[{"title":"memory","content":"memory content","emotion":"happy"}]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/admin/chat/character/register")
|
||||||
|
.file(imageFile())
|
||||||
|
.file(jsonPart(request))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
|
val arguments = Mockito.mockingDetails(service).invocations
|
||||||
|
.single { it.method.name == "createChatCharacterWithDetails" }
|
||||||
|
.arguments
|
||||||
|
assertEquals("remote-id", arguments[0])
|
||||||
|
assertEquals("character", arguments[1])
|
||||||
|
assertEquals("description", arguments[2])
|
||||||
|
assertEquals("prompt", arguments[3])
|
||||||
|
assertEquals(20, arguments[4])
|
||||||
|
assertEquals("여성", arguments[5])
|
||||||
|
assertEquals("INTJ", arguments[6])
|
||||||
|
assertEquals("polite", arguments[7])
|
||||||
|
assertEquals("calm", arguments[8])
|
||||||
|
assertEquals("appearance", arguments[9])
|
||||||
|
assertEquals("original title", arguments[10])
|
||||||
|
assertEquals("https://original.test", arguments[11])
|
||||||
|
assertEquals(CharacterType.Character, arguments[12])
|
||||||
|
assertEquals("KR", arguments[13])
|
||||||
|
assertEquals(listOf("tag"), arguments[14])
|
||||||
|
assertEquals(listOf("value"), arguments[15])
|
||||||
|
assertEquals(listOf("hobby"), arguments[16])
|
||||||
|
assertEquals(listOf("goal"), arguments[17])
|
||||||
|
assertEquals(listOf(Triple("memory", "memory content", "happy")), arguments[18])
|
||||||
|
assertEquals(listOf(Pair("kind", "personality description")), arguments[19])
|
||||||
|
assertEquals(listOf(Pair("past", "background description")), arguments[20])
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
ChatCharacterRelationshipRequest(
|
||||||
|
personName = "person",
|
||||||
|
relationshipName = "friend",
|
||||||
|
description = "relationship description",
|
||||||
|
importance = 5,
|
||||||
|
relationshipType = "ally",
|
||||||
|
currentStatus = "active"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
arguments[21]
|
||||||
|
)
|
||||||
|
Mockito.verify(originalWorkService).assignOneCharacter(10L, 1L)
|
||||||
|
assertRecordedRequest(
|
||||||
|
request = outboundRequests.single(),
|
||||||
|
method = "POST",
|
||||||
|
path = "/api/characters",
|
||||||
|
expectedBody = """
|
||||||
|
{
|
||||||
|
"name":"character",
|
||||||
|
"systemPrompt":"prompt",
|
||||||
|
"description":"description",
|
||||||
|
"region":"KR",
|
||||||
|
"age":"20",
|
||||||
|
"gender":"여성",
|
||||||
|
"mbti":"INTJ",
|
||||||
|
"speechPattern":"polite",
|
||||||
|
"speechStyle":"calm",
|
||||||
|
"appearance":"appearance",
|
||||||
|
"tags":["tag"],
|
||||||
|
"hobbies":["hobby"],
|
||||||
|
"values":["value"],
|
||||||
|
"goals":["goal"],
|
||||||
|
"relationships":[{
|
||||||
|
"personName":"person",
|
||||||
|
"relationshipName":"friend",
|
||||||
|
"description":"relationship description",
|
||||||
|
"importance":5,
|
||||||
|
"relationshipType":"ally",
|
||||||
|
"currentStatus":"active"
|
||||||
|
}],
|
||||||
|
"personalities":[{"trait":"kind","description":"personality description"}],
|
||||||
|
"backgrounds":[{"topic":"past","description":"background description"}],
|
||||||
|
"memories":[{"title":"memory","content":"memory content","emotion":"happy"}]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepLegacyAdminCharacterUpdateResponseSurface() {
|
||||||
|
val outboundRequests = CopyOnWriteArrayList<RecordedHttpRequest>()
|
||||||
|
val server = externalCharacterApiServer(outboundRequests)
|
||||||
|
try {
|
||||||
|
val service = chatCharacterServiceFake()
|
||||||
|
val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(
|
||||||
|
controllerForMutation(server, service, originalWorkService)
|
||||||
|
).build()
|
||||||
|
val request = """
|
||||||
|
{
|
||||||
|
"id":1,
|
||||||
|
"name":"updated character",
|
||||||
|
"systemPrompt":"updated prompt",
|
||||||
|
"description":"updated description",
|
||||||
|
"age":"21",
|
||||||
|
"gender":"남성",
|
||||||
|
"mbti":"ENTP",
|
||||||
|
"speechPattern":"casual",
|
||||||
|
"speechStyle":"bright",
|
||||||
|
"appearance":"updated appearance",
|
||||||
|
"originalTitle":"updated original title",
|
||||||
|
"originalLink":"https://updated-original.test",
|
||||||
|
"originalWorkId":11,
|
||||||
|
"characterType":"Character",
|
||||||
|
"isActive":true,
|
||||||
|
"tags":["updated tag"],
|
||||||
|
"hobbies":["updated hobby"],
|
||||||
|
"values":["updated value"],
|
||||||
|
"goals":["updated goal"],
|
||||||
|
"relationships":[{
|
||||||
|
"personName":"updated person",
|
||||||
|
"relationshipName":"rival",
|
||||||
|
"description":"updated relationship description",
|
||||||
|
"importance":4,
|
||||||
|
"relationshipType":"opponent",
|
||||||
|
"currentStatus":"tense"
|
||||||
|
}],
|
||||||
|
"personalities":[{"trait":"bold","description":"updated personality description"}],
|
||||||
|
"backgrounds":[{"topic":"future","description":"updated background description"}],
|
||||||
|
"memories":[{"title":"updated memory","content":"updated memory content","emotion":"hopeful"}]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/admin/chat/character/update")
|
||||||
|
.file(jsonPart(request))
|
||||||
|
.with { requestBuilder ->
|
||||||
|
requestBuilder.method = "PUT"
|
||||||
|
requestBuilder
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
|
val requestCaptor = ArgumentCaptor.forClass(ChatCharacterUpdateRequest::class.java)
|
||||||
|
Mockito.verify(service).updateChatCharacterWithDetails(Mockito.isNull(), captureRequest(requestCaptor))
|
||||||
|
assertEquals(
|
||||||
|
ChatCharacterUpdateRequest(
|
||||||
|
id = 1L,
|
||||||
|
name = "updated character",
|
||||||
|
systemPrompt = "updated prompt",
|
||||||
|
description = "updated description",
|
||||||
|
age = "21",
|
||||||
|
gender = "남성",
|
||||||
|
mbti = "ENTP",
|
||||||
|
speechPattern = "casual",
|
||||||
|
speechStyle = "bright",
|
||||||
|
appearance = "updated appearance",
|
||||||
|
originalTitle = "updated original title",
|
||||||
|
originalLink = "https://updated-original.test",
|
||||||
|
originalWorkId = 11L,
|
||||||
|
characterType = "Character",
|
||||||
|
isActive = true,
|
||||||
|
tags = listOf("updated tag"),
|
||||||
|
hobbies = listOf("updated hobby"),
|
||||||
|
values = listOf("updated value"),
|
||||||
|
goals = listOf("updated goal"),
|
||||||
|
relationships = listOf(
|
||||||
|
ChatCharacterRelationshipRequest(
|
||||||
|
personName = "updated person",
|
||||||
|
relationshipName = "rival",
|
||||||
|
description = "updated relationship description",
|
||||||
|
importance = 4,
|
||||||
|
relationshipType = "opponent",
|
||||||
|
currentStatus = "tense"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
personalities = listOf(
|
||||||
|
ChatCharacterPersonalityRequest("bold", "updated personality description")
|
||||||
|
),
|
||||||
|
backgrounds = listOf(
|
||||||
|
ChatCharacterBackgroundRequest("future", "updated background description")
|
||||||
|
),
|
||||||
|
memories = listOf(
|
||||||
|
ChatCharacterMemoryRequest("updated memory", "updated memory content", "hopeful")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
requestCaptor.value
|
||||||
|
)
|
||||||
|
Mockito.verify(originalWorkService).assignOneCharacter(11L, 1L)
|
||||||
|
assertRecordedRequest(
|
||||||
|
request = outboundRequests.single(),
|
||||||
|
method = "PUT",
|
||||||
|
path = "/api/characters/remote-id",
|
||||||
|
expectedBody = """
|
||||||
|
{
|
||||||
|
"name":"updated character",
|
||||||
|
"systemPrompt":"updated prompt",
|
||||||
|
"description":"updated description",
|
||||||
|
"age":"21",
|
||||||
|
"gender":"남성",
|
||||||
|
"mbti":"ENTP",
|
||||||
|
"speechPattern":"casual",
|
||||||
|
"speechStyle":"bright",
|
||||||
|
"appearance":"updated appearance",
|
||||||
|
"tags":["updated tag"],
|
||||||
|
"hobbies":["updated hobby"],
|
||||||
|
"values":["updated value"],
|
||||||
|
"goals":["updated goal"],
|
||||||
|
"relationships":[{
|
||||||
|
"personName":"updated person",
|
||||||
|
"relationshipName":"rival",
|
||||||
|
"description":"updated relationship description",
|
||||||
|
"importance":4,
|
||||||
|
"relationshipType":"opponent",
|
||||||
|
"currentStatus":"tense"
|
||||||
|
}],
|
||||||
|
"personalities":[{"trait":"bold","description":"updated personality description"}],
|
||||||
|
"backgrounds":[{"topic":"future","description":"updated background description"}],
|
||||||
|
"memories":[{
|
||||||
|
"title":"updated memory",
|
||||||
|
"content":"updated memory content",
|
||||||
|
"emotion":"hopeful"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldMapFemaleToJapaneseWhenRegionIsJp() {
|
fun shouldMapFemaleToJapaneseWhenRegionIsJp() {
|
||||||
val mappedGender = mapGender(region = "JP", gender = "여성")
|
val mappedGender = mapGender(region = "JP", gender = "여성")
|
||||||
@@ -62,4 +619,143 @@ class AdminChatCharacterControllerTest {
|
|||||||
|
|
||||||
assertEquals("여성", mappedGender)
|
assertEquals("여성", mappedGender)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method {
|
||||||
|
return AdminChatCharacterController::class.java.getDeclaredMethod(name, *parameterTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun controller(
|
||||||
|
adminService: AdminChatCharacterService = Mockito.mock(AdminChatCharacterService::class.java)
|
||||||
|
): AdminChatCharacterController {
|
||||||
|
return AdminChatCharacterController(
|
||||||
|
service = Mockito.mock(ChatCharacterService::class.java),
|
||||||
|
adminService = adminService,
|
||||||
|
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||||
|
originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java),
|
||||||
|
creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java),
|
||||||
|
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||||
|
apiKey = "test-api-key",
|
||||||
|
apiUrl = "https://example.com",
|
||||||
|
s3Bucket = "test-bucket",
|
||||||
|
imageHost = "https://cdn.example.com"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun controllerForMutation(
|
||||||
|
server: HttpServer? = null,
|
||||||
|
service: ChatCharacterService = chatCharacterServiceFake(),
|
||||||
|
originalWorkService: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
): AdminChatCharacterController {
|
||||||
|
val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation ->
|
||||||
|
if (invocation.method.name == "getUrl") {
|
||||||
|
URL("https://cdn.example.com/characters/1/character.png")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AdminChatCharacterController(
|
||||||
|
service = service,
|
||||||
|
adminService = Mockito.mock(AdminChatCharacterService::class.java),
|
||||||
|
s3Uploader = S3Uploader(amazonS3Client),
|
||||||
|
originalWorkService = originalWorkService,
|
||||||
|
creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java),
|
||||||
|
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
|
||||||
|
apiKey = "test-api-key",
|
||||||
|
apiUrl = server?.let { "http://127.0.0.1:${it.address.port}" } ?: "https://example.com",
|
||||||
|
s3Bucket = "test-bucket",
|
||||||
|
imageHost = "https://cdn.example.com"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chatCharacterServiceFake(): ChatCharacterService {
|
||||||
|
val character = chatCharacter(id = 1L, uuid = "remote-id")
|
||||||
|
return Mockito.mock(ChatCharacterService::class.java) { invocation ->
|
||||||
|
when (invocation.method.name) {
|
||||||
|
"findByName" -> null
|
||||||
|
"findById" -> character
|
||||||
|
"createChatCharacterWithDetails" -> character
|
||||||
|
"saveChatCharacter" -> invocation.arguments[0]
|
||||||
|
"updateChatCharacterWithDetails" -> character
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chatCharacter(id: Long, uuid: String): ChatCharacter {
|
||||||
|
return ChatCharacter(
|
||||||
|
characterUUID = uuid,
|
||||||
|
name = "character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt",
|
||||||
|
characterType = CharacterType.Character
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun characterListResponse(): ChatCharacterListResponse {
|
||||||
|
return ChatCharacterListResponse(
|
||||||
|
id = 1L,
|
||||||
|
name = "character",
|
||||||
|
imageUrl = "https://cdn.example.com/characters/1.png",
|
||||||
|
description = "description",
|
||||||
|
gender = "여성",
|
||||||
|
age = 20,
|
||||||
|
mbti = "INTJ",
|
||||||
|
speechStyle = "calm",
|
||||||
|
speechPattern = "polite",
|
||||||
|
region = "KR",
|
||||||
|
tags = listOf("tag"),
|
||||||
|
createdAt = "2026-07-21 12:00:00",
|
||||||
|
updatedAt = "2026-07-21 12:00:01"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureRequest(captor: ArgumentCaptor<ChatCharacterUpdateRequest>): ChatCharacterUpdateRequest {
|
||||||
|
return captor.capture() ?: ChatCharacterUpdateRequest(id = 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun externalCharacterApiServer(requests: MutableList<RecordedHttpRequest>): HttpServer {
|
||||||
|
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||||
|
server.createContext("/api/characters") { exchange ->
|
||||||
|
requests += RecordedHttpRequest(
|
||||||
|
method = exchange.requestMethod,
|
||||||
|
path = exchange.requestURI.path,
|
||||||
|
body = exchange.requestBody.bufferedReader().use { it.readText() }
|
||||||
|
)
|
||||||
|
val response = """{"success":true,"data":{"id":"remote-id"}}""".toByteArray()
|
||||||
|
exchange.sendResponseHeaders(200, response.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(response) }
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertRecordedRequest(
|
||||||
|
request: RecordedHttpRequest,
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
expectedBody: String
|
||||||
|
) {
|
||||||
|
assertEquals(method, request.method)
|
||||||
|
assertEquals(path, request.path)
|
||||||
|
assertEquals(ObjectMapper().readTree(expectedBody), ObjectMapper().readTree(request.body))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageFile(): MockMultipartFile {
|
||||||
|
return MockMultipartFile("image", "character.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonPart(json: String): MockMultipartFile {
|
||||||
|
return MockMultipartFile("request", "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RecordedHttpRequest(
|
||||||
|
val method: String,
|
||||||
|
val path: String,
|
||||||
|
val body: String
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val LEGACY_EMPTY_SUCCESS_RESPONSE =
|
||||||
|
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3Client
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.data.domain.PageImpl
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class AdminOriginalWorkControllerContractTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 관리자 API는 기존 base path와 ADMIN 권한 계약을 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkBaseContract() {
|
||||||
|
val classMapping = AdminOriginalWorkController::class.java.getAnnotation(RequestMapping::class.java)
|
||||||
|
val preAuthorize = AdminOriginalWorkController::class.java.getAnnotation(PreAuthorize::class.java)
|
||||||
|
|
||||||
|
assertEquals("/admin/chat/original", classMapping.value.single())
|
||||||
|
assertEquals("hasRole('ADMIN')", preAuthorize.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 관리자 mutation 경로와 method를 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkMutationRoutes() {
|
||||||
|
assertEquals(
|
||||||
|
"/register",
|
||||||
|
method("register", MultipartFile::class.java, String::class.java)
|
||||||
|
.getAnnotation(PostMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/update",
|
||||||
|
method("update", MultipartFile::class.java, String::class.java)
|
||||||
|
.getAnnotation(PutMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/{id}",
|
||||||
|
method("delete", Long::class.java).getAnnotation(DeleteMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/{id}/assign-characters",
|
||||||
|
method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||||
|
.getAnnotation(PostMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/{id}/unassign-characters",
|
||||||
|
method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||||
|
.getAnnotation(PostMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 관리자 mutation request annotation을 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkMutationRequestAnnotations() {
|
||||||
|
val register = method("register", MultipartFile::class.java, String::class.java)
|
||||||
|
val update = method("update", MultipartFile::class.java, String::class.java)
|
||||||
|
val delete = method("delete", Long::class.java)
|
||||||
|
val assign = method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||||
|
val unassign = method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java)
|
||||||
|
|
||||||
|
assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||||
|
assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||||
|
assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value)
|
||||||
|
assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required)
|
||||||
|
assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value)
|
||||||
|
assertEquals(true, delete.parameters[0].isAnnotationPresent(PathVariable::class.java))
|
||||||
|
assertEquals(true, assign.parameters[0].isAnnotationPresent(PathVariable::class.java))
|
||||||
|
assertEquals(true, assign.parameters[1].isAnnotationPresent(RequestBody::class.java))
|
||||||
|
assertEquals(true, unassign.parameters[0].isAnnotationPresent(PathVariable::class.java))
|
||||||
|
assertEquals(true, unassign.parameters[1].isAnnotationPresent(RequestBody::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 관리자 조회 경로와 request DTO 기본 field를 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkReadRoutesAndDtos() {
|
||||||
|
assertEquals(
|
||||||
|
"/list",
|
||||||
|
method("list", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/search",
|
||||||
|
method("search", String::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/{id}",
|
||||||
|
method("detail", Long::class.java).getAnnotation(GetMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"/{id}/characters",
|
||||||
|
method("listCharactersOfOriginal", Long::class.java, Int::class.java, Int::class.java)
|
||||||
|
.getAnnotation(GetMapping::class.java).value.single()
|
||||||
|
)
|
||||||
|
|
||||||
|
val register = OriginalWorkRegisterRequest(title = "title", contentType = "type", category = "category")
|
||||||
|
val registerWithAllFields = OriginalWorkRegisterRequest(
|
||||||
|
title = "title",
|
||||||
|
contentType = "type",
|
||||||
|
category = "category",
|
||||||
|
isAdult = true,
|
||||||
|
description = "description",
|
||||||
|
originalWork = "source",
|
||||||
|
originalLink = "https://source.test",
|
||||||
|
writer = "writer",
|
||||||
|
studio = "studio",
|
||||||
|
originalLinks = listOf("https://link.test"),
|
||||||
|
tags = listOf("tag")
|
||||||
|
)
|
||||||
|
val update = OriginalWorkUpdateRequest(
|
||||||
|
id = 1L,
|
||||||
|
title = "title",
|
||||||
|
contentType = "type",
|
||||||
|
category = "category",
|
||||||
|
isAdult = null,
|
||||||
|
description = "description",
|
||||||
|
originalWork = "source",
|
||||||
|
originalLink = "https://source.test",
|
||||||
|
writer = "writer",
|
||||||
|
studio = "studio",
|
||||||
|
originalLinks = listOf("https://link.test"),
|
||||||
|
tags = listOf("tag")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("title", register.title)
|
||||||
|
assertEquals(false, register.isAdult)
|
||||||
|
assertEquals(true, registerWithAllFields.isAdult)
|
||||||
|
assertEquals("source", registerWithAllFields.originalWork)
|
||||||
|
assertEquals("https://source.test", registerWithAllFields.originalLink)
|
||||||
|
assertEquals("writer", registerWithAllFields.writer)
|
||||||
|
assertEquals("studio", registerWithAllFields.studio)
|
||||||
|
assertEquals(listOf("https://link.test"), registerWithAllFields.originalLinks)
|
||||||
|
assertEquals(listOf("tag"), registerWithAllFields.tags)
|
||||||
|
assertEquals(1L, update.id)
|
||||||
|
assertEquals("type", update.contentType)
|
||||||
|
assertEquals("category", update.category)
|
||||||
|
assertEquals(null, update.isAdult)
|
||||||
|
assertEquals("description", update.description)
|
||||||
|
assertEquals("source", update.originalWork)
|
||||||
|
assertEquals("https://source.test", update.originalLink)
|
||||||
|
assertEquals("writer", update.writer)
|
||||||
|
assertEquals("studio", update.studio)
|
||||||
|
assertEquals(listOf("https://link.test"), update.originalLinks)
|
||||||
|
assertEquals(listOf("tag"), update.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 관리자 목록은 기존 성공 응답 surface를 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkListResponseSurface() {
|
||||||
|
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
Mockito.`when`(service.getOriginalWorkPage(0, 20))
|
||||||
|
.thenReturn(PageImpl(listOf(originalWork()), PageRequest.of(0, 20), 1))
|
||||||
|
val controller = AdminOriginalWorkController(
|
||||||
|
originalWorkService = service,
|
||||||
|
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||||
|
s3Bucket = "test-bucket",
|
||||||
|
imageHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/chat/original/list").param("page", "0").param("size", "20"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].title").value("title"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].contentType").value("webtoon"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].category").value("romance"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].isAdult").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].description").value("description"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].originalWork").value("source"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].originalLink").value("https://source.test"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].writer").value("writer"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].studio").value("studio"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].originalLinks[0]").value("https://link.test"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].tags[0]").value("tag"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 검색은 searchTerm을 service에 전달하고 기존 성공 응답을 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkSearchRequestAndResponseSurface() {
|
||||||
|
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
Mockito.`when`(service.searchOriginalWorksAll("title")).thenReturn(listOf(originalWork()))
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/chat/original/search").param("searchTerm", "title"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data").isArray)
|
||||||
|
.andExpect(jsonPath("$.data[0].id").value(1))
|
||||||
|
.andExpect(jsonPath("$.data[0].title").value("title"))
|
||||||
|
.andExpect(jsonPath("$.data[0].contentType").value("webtoon"))
|
||||||
|
.andExpect(jsonPath("$.data[0].category").value("romance"))
|
||||||
|
.andExpect(jsonPath("$.data[0].isAdult").value(false))
|
||||||
|
.andExpect(jsonPath("$.data[0].description").value("description"))
|
||||||
|
.andExpect(jsonPath("$.data[0].originalWork").value("source"))
|
||||||
|
.andExpect(jsonPath("$.data[0].originalLink").value("https://source.test"))
|
||||||
|
.andExpect(jsonPath("$.data[0].writer").value("writer"))
|
||||||
|
.andExpect(jsonPath("$.data[0].studio").value("studio"))
|
||||||
|
.andExpect(jsonPath("$.data[0].originalLinks[0]").value("https://link.test"))
|
||||||
|
.andExpect(jsonPath("$.data[0].tags[0]").value("tag"))
|
||||||
|
.andExpect(jsonPath("$.data[0].imageUrl").value("https://cdn.test/originals/1.png"))
|
||||||
|
|
||||||
|
Mockito.verify(service).searchOriginalWorksAll("title")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 관리자 상세은 기존 성공 응답 surface를 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkDetailResponseSurface() {
|
||||||
|
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
Mockito.`when`(service.getOriginalWork(1L)).thenReturn(originalWork())
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/chat/original/1"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.id").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.title").value("title"))
|
||||||
|
.andExpect(jsonPath("$.data.contentType").value("webtoon"))
|
||||||
|
.andExpect(jsonPath("$.data.category").value("romance"))
|
||||||
|
.andExpect(jsonPath("$.data.isAdult").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.description").value("description"))
|
||||||
|
.andExpect(jsonPath("$.data.originalWork").value("source"))
|
||||||
|
.andExpect(jsonPath("$.data.originalLink").value("https://source.test"))
|
||||||
|
.andExpect(jsonPath("$.data.writer").value("writer"))
|
||||||
|
.andExpect(jsonPath("$.data.studio").value("studio"))
|
||||||
|
.andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test"))
|
||||||
|
.andExpect(jsonPath("$.data.tags[0]").value("tag"))
|
||||||
|
.andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 연결 캐릭터 목록은 기존 성공 응답 surface를 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkCharactersResponseSurface() {
|
||||||
|
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "uuid",
|
||||||
|
name = "character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
).apply {
|
||||||
|
id = 10L
|
||||||
|
imagePath = "characters/10.png"
|
||||||
|
}
|
||||||
|
Mockito.`when`(service.getCharactersOfOriginalWorkPage(1L, 0, 20))
|
||||||
|
.thenReturn(PageImpl(listOf(character), PageRequest.of(0, 20), 1))
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/chat/original/1/characters").param("page", "0").param("size", "20"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.content[0].id").value(10))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].name").value("character"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].imagePath").value("https://cdn.test/characters/10.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 관리자 mutation은 기존 성공 응답 surface를 유지한다")
|
||||||
|
fun shouldKeepLegacyAdminOriginalWorkMutationResponseSurface() {
|
||||||
|
val service = Mockito.mock(AdminOriginalWorkService::class.java)
|
||||||
|
val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation ->
|
||||||
|
if (invocation.method.name == "getUrl") {
|
||||||
|
URL("https://cdn.test/originals/1/original.png")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val s3Uploader = S3Uploader(amazonS3Client)
|
||||||
|
val saved = OriginalWork(title = "title", contentType = "type", category = "category").apply { id = 1L }
|
||||||
|
val createRequest = OriginalWorkRegisterRequest(
|
||||||
|
title = "title",
|
||||||
|
contentType = "type",
|
||||||
|
category = "category",
|
||||||
|
isAdult = false,
|
||||||
|
description = "",
|
||||||
|
originalWork = "source",
|
||||||
|
originalLink = "https://source.test",
|
||||||
|
writer = "writer",
|
||||||
|
studio = "studio",
|
||||||
|
originalLinks = listOf("https://link.test"),
|
||||||
|
tags = listOf("tag")
|
||||||
|
)
|
||||||
|
Mockito.`when`(service.createOriginalWork(createRequest)).thenReturn(saved)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service, s3Uploader = s3Uploader)).build()
|
||||||
|
|
||||||
|
val registerRequest = """
|
||||||
|
{
|
||||||
|
"title":"title",
|
||||||
|
"contentType":"type",
|
||||||
|
"category":"category",
|
||||||
|
"isAdult":false,
|
||||||
|
"description":"",
|
||||||
|
"originalWork":"source",
|
||||||
|
"originalLink":"https://source.test",
|
||||||
|
"writer":"writer",
|
||||||
|
"studio":"studio",
|
||||||
|
"originalLinks":["https://link.test"],
|
||||||
|
"tags":["tag"]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
mockMvc.perform(multipart("/admin/chat/original/register").file(imageFile()).file(jsonPart("request", registerRequest)))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
|
val updateRequest = """
|
||||||
|
{
|
||||||
|
"id":1,
|
||||||
|
"title":"title",
|
||||||
|
"contentType":"type",
|
||||||
|
"category":"category",
|
||||||
|
"isAdult":null,
|
||||||
|
"description":"description",
|
||||||
|
"originalWork":"source",
|
||||||
|
"originalLink":"https://source.test",
|
||||||
|
"writer":"writer",
|
||||||
|
"studio":"studio",
|
||||||
|
"originalLinks":["https://link.test"],
|
||||||
|
"tags":["tag"]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/admin/chat/original/update")
|
||||||
|
.file(jsonPart("request", updateRequest))
|
||||||
|
.with { request ->
|
||||||
|
request.method = "PUT"
|
||||||
|
request
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
|
val updateRequestCaptor = ArgumentCaptor.forClass(OriginalWorkUpdateRequest::class.java)
|
||||||
|
Mockito.verify(service).updateOriginalWork(captureUpdateRequest(updateRequestCaptor), Mockito.isNull())
|
||||||
|
assertEquals(
|
||||||
|
OriginalWorkUpdateRequest(
|
||||||
|
id = 1L,
|
||||||
|
title = "title",
|
||||||
|
contentType = "type",
|
||||||
|
category = "category",
|
||||||
|
isAdult = null,
|
||||||
|
description = "description",
|
||||||
|
originalWork = "source",
|
||||||
|
originalLink = "https://source.test",
|
||||||
|
writer = "writer",
|
||||||
|
studio = "studio",
|
||||||
|
originalLinks = listOf("https://link.test"),
|
||||||
|
tags = listOf("tag")
|
||||||
|
),
|
||||||
|
updateRequestCaptor.value
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/admin/chat/original/1"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||||
|
Mockito.verify(service).deleteOriginalWork(1L)
|
||||||
|
|
||||||
|
val assignBody = """{"characterIds":[1]}"""
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/admin/chat/original/1/assign-characters")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(assignBody)
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||||
|
Mockito.verify(service).assignCharacters(1L, listOf(1L))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/admin/chat/original/1/unassign-characters")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(assignBody)
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true))
|
||||||
|
Mockito.verify(service).unassignCharacters(1L, listOf(1L))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun controller(
|
||||||
|
service: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java),
|
||||||
|
s3Uploader: S3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||||
|
): AdminOriginalWorkController {
|
||||||
|
return AdminOriginalWorkController(
|
||||||
|
originalWorkService = service,
|
||||||
|
s3Uploader = s3Uploader,
|
||||||
|
s3Bucket = "test-bucket",
|
||||||
|
imageHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageFile(): MockMultipartFile {
|
||||||
|
return MockMultipartFile("image", "original.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun originalWork(): OriginalWork {
|
||||||
|
val originalWork = OriginalWork(
|
||||||
|
title = "title",
|
||||||
|
contentType = "webtoon",
|
||||||
|
category = "romance",
|
||||||
|
isAdult = false,
|
||||||
|
description = "description",
|
||||||
|
originalWork = "source",
|
||||||
|
originalLink = "https://source.test",
|
||||||
|
writer = "writer",
|
||||||
|
studio = "studio"
|
||||||
|
).apply {
|
||||||
|
id = 1L
|
||||||
|
imagePath = "originals/1.png"
|
||||||
|
}
|
||||||
|
originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork)
|
||||||
|
originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag"))
|
||||||
|
return originalWork
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonPart(name: String, json: String): MockMultipartFile {
|
||||||
|
return MockMultipartFile(name, "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method {
|
||||||
|
return AdminOriginalWorkController::class.java.getDeclaredMethod(name, *parameterTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureUpdateRequest(
|
||||||
|
captor: ArgumentCaptor<OriginalWorkUpdateRequest>
|
||||||
|
): OriginalWorkUpdateRequest {
|
||||||
|
return captor.capture() ?: OriginalWorkUpdateRequest(id = 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val LEGACY_EMPTY_SUCCESS_RESPONSE =
|
||||||
|
"""{"success":true,"message":null,"data":null,"errorProperty":null}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.member.Member
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberToken
|
||||||
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
@@ -18,19 +19,22 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
|||||||
|
|
||||||
class AdminMemberLoginServiceTest {
|
class AdminMemberLoginServiceTest {
|
||||||
private lateinit var repository: AdminMemberRepository
|
private lateinit var repository: AdminMemberRepository
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
private lateinit var passwordEncoder: PasswordEncoder
|
private lateinit var passwordEncoder: PasswordEncoder
|
||||||
private lateinit var tokenRepository: MemberTokenRepository
|
private lateinit var tokenRepository: MemberTokenRepository
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
private lateinit var service: AdminMemberLoginService
|
private lateinit var service: AdminMemberLoginService
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setup() {
|
fun setup() {
|
||||||
repository = mock()
|
repository = mock()
|
||||||
|
memberRepository = mock()
|
||||||
passwordEncoder = mock()
|
passwordEncoder = mock()
|
||||||
tokenRepository = mock()
|
tokenRepository = mock()
|
||||||
val tokenProvider = TokenProvider(
|
tokenProvider = TokenProvider(
|
||||||
secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||||
tokenValidityInSeconds = 3600,
|
tokenValidityInSeconds = 3600,
|
||||||
repository = mock<MemberRepository>(),
|
repository = memberRepository,
|
||||||
tokenRepository = tokenRepository
|
tokenRepository = tokenRepository
|
||||||
)
|
)
|
||||||
tokenProvider.afterPropertiesSet()
|
tokenProvider.afterPropertiesSet()
|
||||||
@@ -54,6 +58,27 @@ class AdminMemberLoginServiceTest {
|
|||||||
assertEquals(MemberRole.ADMIN, response.role)
|
assertEquals(MemberRole.ADMIN, response.role)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 로그인 token은 기존 TokenProvider로 검증하고 인증 정보를 복원할 수 있다")
|
||||||
|
fun shouldCreateUsableAdminToken() {
|
||||||
|
val member = createMember(id = 1L, role = MemberRole.ADMIN)
|
||||||
|
var savedToken: MemberToken? = null
|
||||||
|
Mockito.`when`(repository.findByEmail("admin@test.com")).thenReturn(member)
|
||||||
|
Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(java.util.Optional.of(member))
|
||||||
|
Mockito.`when`(tokenRepository.findById(Mockito.eq(1L))).thenAnswer {
|
||||||
|
java.util.Optional.ofNullable(savedToken)
|
||||||
|
}
|
||||||
|
Mockito.`when`(tokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as MemberToken).also { savedToken = it }
|
||||||
|
}
|
||||||
|
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||||
|
|
||||||
|
val response = service.login(LoginRequest(email = "admin@test.com", password = "password"))
|
||||||
|
|
||||||
|
assertTrue(tokenProvider.validateToken(response.token))
|
||||||
|
assertEquals(member.email, tokenProvider.getAuthentication(response.token).name)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다")
|
@DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다")
|
||||||
fun shouldLoginContentManager() {
|
fun shouldLoginContentManager() {
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original.controller
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.core.MethodParameter
|
||||||
|
import org.springframework.data.domain.PageImpl
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.support.WebDataBinderFactory
|
||||||
|
import org.springframework.web.context.request.NativeWebRequest
|
||||||
|
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||||
|
import org.springframework.web.method.support.ModelAndViewContainer
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@WebMvcTest(OriginalWorkController::class)
|
||||||
|
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||||
|
class OriginalWorkControllerContractTest @Autowired constructor(
|
||||||
|
private val securityMockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var securedQueryService: OriginalWorkQueryService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var securedCharacterImageRepository: CharacterImageRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var securedMemberContentPreferenceService: MemberContentPreferenceService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var securedLangContext: LangContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var securedOriginalWorkTranslationService: OriginalWorkTranslationService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var securedOriginalWorkTranslationRepository: OriginalWorkTranslationRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var securedAiCharacterTranslationRepository: AiCharacterTranslationRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 사용자 원작 API는 기존 base path와 공개 목록 경로를 유지한다")
|
||||||
|
fun shouldKeepConsumerOriginalWorkListRoute() {
|
||||||
|
val classMapping = OriginalWorkController::class.java.getAnnotation(RequestMapping::class.java)
|
||||||
|
val list = OriginalWorkController::class.java.getDeclaredMethod(
|
||||||
|
"list",
|
||||||
|
Int::class.java,
|
||||||
|
Int::class.java,
|
||||||
|
Member::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("/api/chat/original", classMapping.value.single())
|
||||||
|
assertEquals("/list", list.getAnnotation(GetMapping::class.java).value.single())
|
||||||
|
assertNull(list.getAnnotation(PreAuthorize::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 사용자 원작 상세 API는 기존 경로를 유지한다")
|
||||||
|
fun shouldKeepConsumerOriginalWorkDetailRoute() {
|
||||||
|
val detail = OriginalWorkController::class.java.getDeclaredMethod("detail", Long::class.java, Member::class.java)
|
||||||
|
|
||||||
|
assertEquals("/{id}", detail.getAnnotation(GetMapping::class.java).value.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("production security matcher는 익명 원작 목록은 허용하고 상세는 거부한다")
|
||||||
|
fun shouldApplyProductionSecurityMatcherToAnonymousOriginalWorkRequests() {
|
||||||
|
Mockito.`when`(securedQueryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(emptyList()))
|
||||||
|
|
||||||
|
securityMockMvc.perform(get("/api/chat/original/list").with(anonymous()))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
securityMockMvc.perform(get("/api/chat/original/1").with(anonymous()))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 사용자 원작 목록은 기존 성공 응답 surface를 유지한다")
|
||||||
|
fun shouldKeepConsumerOriginalWorkListResponseSurface() {
|
||||||
|
val queryService = Mockito.mock(OriginalWorkQueryService::class.java)
|
||||||
|
val originalWork = originalWork()
|
||||||
|
Mockito.`when`(queryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(listOf(originalWork)))
|
||||||
|
val langContext = Mockito.mock(LangContext::class.java)
|
||||||
|
Mockito.`when`(langContext.lang).thenReturn(Lang.KO)
|
||||||
|
val originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java)
|
||||||
|
Mockito.`when`(originalWorkTranslationRepository.findByOriginalWorkIdInAndLocale(setOf(1L), "ko"))
|
||||||
|
.thenReturn(emptyList())
|
||||||
|
val controller = OriginalWorkController(
|
||||||
|
queryService = queryService,
|
||||||
|
characterImageRepository = Mockito.mock(CharacterImageRepository::class.java),
|
||||||
|
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java),
|
||||||
|
langContext = langContext,
|
||||||
|
originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java),
|
||||||
|
originalWorkTranslationRepository = originalWorkTranslationRepository,
|
||||||
|
aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java),
|
||||||
|
imageHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||||
|
.setCustomArgumentResolvers(AnonymousMemberArgumentResolver())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/chat/original/list").param("page", "0").param("size", "20"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.content[0].id").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].title").value("title"))
|
||||||
|
.andExpect(jsonPath("$.data.content[0].contentType").value("webtoon"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 사용자 원작 상세는 기존 성공 응답 field를 유지한다")
|
||||||
|
fun shouldKeepConsumerOriginalWorkDetailResponseSurface() {
|
||||||
|
val queryService = Mockito.mock(OriginalWorkQueryService::class.java)
|
||||||
|
val originalWork = originalWork()
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "character-uuid",
|
||||||
|
name = "character",
|
||||||
|
description = "character description",
|
||||||
|
systemPrompt = "prompt"
|
||||||
|
).apply {
|
||||||
|
id = 10L
|
||||||
|
imagePath = "characters/10.png"
|
||||||
|
}
|
||||||
|
Mockito.`when`(queryService.getOriginalWork(1L)).thenReturn(originalWork)
|
||||||
|
Mockito.`when`(queryService.getActiveCharactersPage(1L, 0, 20)).thenReturn(PageImpl(listOf(character)))
|
||||||
|
val characterImageRepository = Mockito.mock(CharacterImageRepository::class.java)
|
||||||
|
Mockito.`when`(
|
||||||
|
characterImageRepository.findCharacterIdsWithRecentImages(
|
||||||
|
Mockito.eq(listOf(10L)) ?: listOf(10L),
|
||||||
|
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.thenReturn(listOf(10L))
|
||||||
|
val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
|
val member = Member(email = "user@test.com", password = "password", nickname = "user").apply { id = 20L }
|
||||||
|
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member))
|
||||||
|
.thenReturn(ViewerContentPreference("KR", true, ContentType.ALL, true))
|
||||||
|
val langContext = Mockito.mock(LangContext::class.java)
|
||||||
|
Mockito.`when`(langContext.lang).thenReturn(Lang.KO)
|
||||||
|
val originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java)
|
||||||
|
Mockito.`when`(originalWorkTranslationService.ensureTranslated(originalWork, "ko")).thenReturn(null)
|
||||||
|
val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
|
||||||
|
Mockito.`when`(aiCharacterTranslationRepository.findByCharacterIdInAndLocale(listOf(10L), "ko"))
|
||||||
|
.thenReturn(emptyList())
|
||||||
|
val controller = OriginalWorkController(
|
||||||
|
queryService = queryService,
|
||||||
|
characterImageRepository = characterImageRepository,
|
||||||
|
memberContentPreferenceService = memberContentPreferenceService,
|
||||||
|
langContext = langContext,
|
||||||
|
originalWorkTranslationService = originalWorkTranslationService,
|
||||||
|
originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java),
|
||||||
|
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
|
||||||
|
imageHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
val mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||||
|
.setCustomArgumentResolvers(MemberArgumentResolver(member))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/chat/original/1"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png"))
|
||||||
|
.andExpect(jsonPath("$.data.title").value("title"))
|
||||||
|
.andExpect(jsonPath("$.data.contentType").value("webtoon"))
|
||||||
|
.andExpect(jsonPath("$.data.category").value("romance"))
|
||||||
|
.andExpect(jsonPath("$.data.isAdult").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.description").value("description"))
|
||||||
|
.andExpect(jsonPath("$.data.originalWork").value("source"))
|
||||||
|
.andExpect(jsonPath("$.data.originalLink").value("https://source.test"))
|
||||||
|
.andExpect(jsonPath("$.data.writer").value("writer"))
|
||||||
|
.andExpect(jsonPath("$.data.studio").value("studio"))
|
||||||
|
.andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test"))
|
||||||
|
.andExpect(jsonPath("$.data.tags[0]").value("tag"))
|
||||||
|
.andExpect(jsonPath("$.data.characters[0].characterId").value(10))
|
||||||
|
.andExpect(jsonPath("$.data.characters[0].name").value("character"))
|
||||||
|
.andExpect(jsonPath("$.data.characters[0].description").value("character description"))
|
||||||
|
.andExpect(jsonPath("$.data.characters[0].imageUrl").value("https://cdn.test/characters/10.png"))
|
||||||
|
.andExpect(jsonPath("$.data.characters[0].isNew").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.translated").doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AnonymousMemberArgumentResolver : HandlerMethodArgumentResolver {
|
||||||
|
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||||
|
return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resolveArgument(
|
||||||
|
parameter: MethodParameter,
|
||||||
|
mavContainer: ModelAndViewContainer?,
|
||||||
|
webRequest: NativeWebRequest,
|
||||||
|
binderFactory: WebDataBinderFactory?
|
||||||
|
): Any? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MemberArgumentResolver(private val member: Member) : HandlerMethodArgumentResolver {
|
||||||
|
override fun supportsParameter(parameter: MethodParameter): Boolean {
|
||||||
|
return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resolveArgument(
|
||||||
|
parameter: MethodParameter,
|
||||||
|
mavContainer: ModelAndViewContainer?,
|
||||||
|
webRequest: NativeWebRequest,
|
||||||
|
binderFactory: WebDataBinderFactory?
|
||||||
|
): Any = member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun originalWork(): OriginalWork {
|
||||||
|
val originalWork = OriginalWork(
|
||||||
|
title = "title",
|
||||||
|
contentType = "webtoon",
|
||||||
|
category = "romance",
|
||||||
|
isAdult = false,
|
||||||
|
description = "description",
|
||||||
|
originalWork = "source",
|
||||||
|
originalLink = "https://source.test",
|
||||||
|
writer = "writer",
|
||||||
|
studio = "studio"
|
||||||
|
).apply {
|
||||||
|
id = 1L
|
||||||
|
imagePath = "originals/1.png"
|
||||||
|
}
|
||||||
|
originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork)
|
||||||
|
originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag"))
|
||||||
|
return originalWork
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import org.hamcrest.Matchers.anEmptyMap
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
|
||||||
|
@WebMvcTest(AudioContentController::class)
|
||||||
|
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||||
|
class AudioContentUploadCompletionContractTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var service: AudioContentService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var langContext: LangContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("upload-complete callback은 기존 PUT 경로와 ADMIN/BOT 권한 계약을 유지한다")
|
||||||
|
fun shouldKeepUploadCompleteRouteAndRoles() {
|
||||||
|
val classMapping = AudioContentController::class.java.getAnnotation(RequestMapping::class.java)
|
||||||
|
val method = AudioContentController::class.java.getDeclaredMethod(
|
||||||
|
"uploadComplete",
|
||||||
|
UploadCompleteRequest::class.java,
|
||||||
|
kr.co.vividnext.sodalive.member.Member::class.java
|
||||||
|
)
|
||||||
|
val putMapping = method.getAnnotation(PutMapping::class.java)
|
||||||
|
val preAuthorize = method.getAnnotation(PreAuthorize::class.java)
|
||||||
|
|
||||||
|
assertEquals("/audio-content", classMapping.value.single())
|
||||||
|
assertEquals("/upload-complete", putMapping.value.single())
|
||||||
|
assertEquals("hasAnyRole('ADMIN', 'BOT')", preAuthorize.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("upload-complete request는 기존 contentId/contentPath/duration field를 유지한다")
|
||||||
|
fun shouldKeepUploadCompleteRequestFields() {
|
||||||
|
val request = UploadCompleteRequest(contentId = 1L, contentPath = "1/output.mp3", duration = "00:01:00")
|
||||||
|
|
||||||
|
assertEquals(1L, request.contentId)
|
||||||
|
assertEquals("1/output.mp3", request.contentPath)
|
||||||
|
assertEquals("00:01:00", request.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("upload-complete callback은 기존 성공 응답 surface를 유지한다")
|
||||||
|
fun shouldKeepUploadCompleteSuccessResponseSurface() {
|
||||||
|
givenToken("bot-token", MemberRole.BOT)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
put("/audio-content/upload-complete")
|
||||||
|
.header("Authorization", "Bearer bot-token")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}""")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data").value(anEmptyMap<Any, Any>()))
|
||||||
|
.andExpect(content().json(LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE, true))
|
||||||
|
|
||||||
|
verify(service).uploadComplete(1L, "1/output.mp3", "00:01:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("upload-complete callback은 ADMIN/BOT JWT만 허용하고 USER/invalid JWT/익명 요청은 거부한다")
|
||||||
|
fun shouldAuthorizeUploadCompleteWithProductionSecurityChain() {
|
||||||
|
givenToken("admin-token", MemberRole.ADMIN)
|
||||||
|
givenToken("bot-token", MemberRole.BOT)
|
||||||
|
givenToken("user-token", MemberRole.USER)
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
|
||||||
|
|
||||||
|
mockMvc.perform(uploadCompleteRequest("user-token"))
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
|
||||||
|
mockMvc.perform(uploadCompleteRequest("invalid-token"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
|
||||||
|
mockMvc.perform(uploadCompleteRequest("admin-token"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
|
||||||
|
mockMvc.perform(uploadCompleteRequest("bot-token"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
put("/audio-content/upload-complete")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(UPLOAD_COMPLETE_REQUEST)
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadCompleteRequest(token: String) = put("/audio-content/upload-complete")
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(UPLOAD_COMPLETE_REQUEST)
|
||||||
|
|
||||||
|
private fun givenToken(token: String, role: MemberRole) {
|
||||||
|
val member = Member(
|
||||||
|
email = "${role.name.lowercase()}@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = role.name.lowercase(),
|
||||||
|
role = role
|
||||||
|
).apply { id = role.ordinal.toLong() + 1 }
|
||||||
|
val authentication = UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(member),
|
||||||
|
token,
|
||||||
|
MemberAdapter(member).authorities
|
||||||
|
)
|
||||||
|
Mockito.`when`(tokenProvider.validateToken(token)).thenReturn(true)
|
||||||
|
Mockito.`when`(tokenProvider.getAuthentication(token)).thenReturn(authentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val UPLOAD_COMPLETE_REQUEST =
|
||||||
|
"""{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}"""
|
||||||
|
|
||||||
|
private const val LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE =
|
||||||
|
"""{"success":true,"message":null,"data":{},"errorProperty":null}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package kr.co.vividnext.sodalive.legacy
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@DataJpaTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:legacy-admin-search-contract;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class LegacyAdminSearchQueryContractTest @Autowired constructor(
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
private val characterService = AdminChatCharacterService(chatCharacterRepository)
|
||||||
|
private val originalWorkService = AdminOriginalWorkService(
|
||||||
|
originalWorkRepository = originalWorkRepository,
|
||||||
|
chatCharacterRepository = chatCharacterRepository,
|
||||||
|
originalWorkTagRepository = Mockito.mock(OriginalWorkTagRepository::class.java),
|
||||||
|
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 캐릭터 검색은 이름, 설명, MBTI, 태그를 검색하고 비활성 캐릭터를 제외한다")
|
||||||
|
fun shouldSearchActiveLegacyCharactersByEverySupportedField() {
|
||||||
|
val nameId = saveCharacter(name = "NameNeedle", description = "plain-name-description").id!!
|
||||||
|
val descriptionId = saveCharacter(name = "description-character", description = "DescriptionNeedle").id!!
|
||||||
|
val mbtiId = saveCharacter(
|
||||||
|
name = "mbti-character",
|
||||||
|
description = "plain-mbti-description",
|
||||||
|
mbti = "MbtiNeedle"
|
||||||
|
).id!!
|
||||||
|
val tagId = saveCharacter(name = "tag-character", description = "plain-tag-description", tag = "TagNeedle").id!!
|
||||||
|
saveCharacter(name = "InactiveNeedle", description = "inactive-description", isActive = false)
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
assertEquals(listOf(nameId), searchCharacterIds("nameneedle"))
|
||||||
|
assertEquals(listOf(descriptionId), searchCharacterIds("descriptionneedle"))
|
||||||
|
assertEquals(listOf(mbtiId), searchCharacterIds("mbtineedle"))
|
||||||
|
assertEquals(listOf(tagId), searchCharacterIds("tagneedle"))
|
||||||
|
assertEquals(emptyList<Long>(), searchCharacterIds("inactiveneedle"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 캐릭터 검색은 최신순과 비기본 page, size, totalCount 계약을 유지한다")
|
||||||
|
fun shouldPageLegacyCharacterSearchByCreatedAtDescending() {
|
||||||
|
val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0)
|
||||||
|
repeat(5) { index ->
|
||||||
|
saveCharacter(
|
||||||
|
name = "paging-needle-$index",
|
||||||
|
description = "paging-description-$index",
|
||||||
|
createdAt = baseTime.plusMinutes(index.toLong())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
saveCharacter(
|
||||||
|
name = "paging-needle-inactive",
|
||||||
|
description = "paging-inactive-description",
|
||||||
|
isActive = false,
|
||||||
|
createdAt = baseTime.plusMinutes(10)
|
||||||
|
)
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val pageable = characterService.createDefaultPageRequest(page = 1, size = 2)
|
||||||
|
val result = characterService.searchCharacters("paging-needle", pageable)
|
||||||
|
|
||||||
|
assertEquals(5L, result.totalElements)
|
||||||
|
assertEquals(1, result.number)
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertEquals(listOf("paging-needle-2", "paging-needle-1"), result.content.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 검색은 제목, 콘텐츠 타입, 카테고리를 검색하고 삭제 원작을 제외한다")
|
||||||
|
fun shouldSearchNonDeletedLegacyOriginalWorksByEverySupportedField() {
|
||||||
|
val titleId = saveOriginalWork(
|
||||||
|
title = "TitleNeedle",
|
||||||
|
contentType = "plain-title-type",
|
||||||
|
category = "plain-title-category"
|
||||||
|
).id!!
|
||||||
|
val contentTypeId = saveOriginalWork(
|
||||||
|
title = "content-type-work",
|
||||||
|
contentType = "ContentTypeNeedle",
|
||||||
|
category = "plain-content-type-category"
|
||||||
|
).id!!
|
||||||
|
val categoryId = saveOriginalWork(
|
||||||
|
title = "category-work",
|
||||||
|
contentType = "plain-category-type",
|
||||||
|
category = "CategoryNeedle"
|
||||||
|
).id!!
|
||||||
|
saveOriginalWork(
|
||||||
|
title = "DeletedNeedle",
|
||||||
|
contentType = "deleted-type",
|
||||||
|
category = "deleted-category",
|
||||||
|
isDeleted = true
|
||||||
|
)
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
assertEquals(listOf(titleId), searchOriginalWorkIds("titleneedle"))
|
||||||
|
assertEquals(listOf(contentTypeId), searchOriginalWorkIds("contenttypeneedle"))
|
||||||
|
assertEquals(listOf(categoryId), searchOriginalWorkIds("categoryneedle"))
|
||||||
|
assertEquals(emptyList<Long>(), searchOriginalWorkIds("deletedneedle"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 원작 검색은 최신순 무페이징 목록 계약을 유지한다")
|
||||||
|
fun shouldReturnAllLegacyOriginalWorkSearchResultsByCreatedAtDescending() {
|
||||||
|
val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0)
|
||||||
|
repeat(3) { index ->
|
||||||
|
saveOriginalWork(
|
||||||
|
title = "ordering-needle-$index",
|
||||||
|
contentType = "ordering-type-$index",
|
||||||
|
category = "ordering-category-$index",
|
||||||
|
createdAt = baseTime.plusMinutes(index.toLong())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val result = originalWorkService.searchOriginalWorksAll("ordering-needle")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("ordering-needle-2", "ordering-needle-1", "ordering-needle-0"),
|
||||||
|
result.map { it.title }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchCharacterIds(searchTerm: String): List<Long> {
|
||||||
|
val pageable = characterService.createDefaultPageRequest(page = 0, size = 20)
|
||||||
|
return characterService.searchCharacters(searchTerm, pageable).content.map { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchOriginalWorkIds(searchTerm: String): List<Long> {
|
||||||
|
return originalWorkService.searchOriginalWorksAll(searchTerm).map { it.id!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCharacter(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
mbti: String? = null,
|
||||||
|
tag: String? = null,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
createdAt: LocalDateTime? = null
|
||||||
|
): ChatCharacter {
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "$name-uuid",
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
systemPrompt = "$name-system-prompt",
|
||||||
|
mbti = mbti,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
character.creatorMember = Member(
|
||||||
|
email = "$name@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "$name-creator"
|
||||||
|
).also(entityManager::persist)
|
||||||
|
tag?.let { tagName ->
|
||||||
|
val tagEntity = ChatCharacterTag(tagName).also(entityManager::persist)
|
||||||
|
character.addTag(tagEntity)
|
||||||
|
}
|
||||||
|
chatCharacterRepository.saveAndFlush(character)
|
||||||
|
if (createdAt != null) {
|
||||||
|
character.createdAt = createdAt
|
||||||
|
entityManager.flush()
|
||||||
|
}
|
||||||
|
return character
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveOriginalWork(
|
||||||
|
title: String,
|
||||||
|
contentType: String,
|
||||||
|
category: String,
|
||||||
|
isDeleted: Boolean = false,
|
||||||
|
createdAt: LocalDateTime? = null
|
||||||
|
): OriginalWork {
|
||||||
|
val originalWork = OriginalWork(
|
||||||
|
title = title,
|
||||||
|
contentType = contentType,
|
||||||
|
category = category
|
||||||
|
).apply { this.isDeleted = isDeleted }
|
||||||
|
originalWorkRepository.saveAndFlush(originalWork)
|
||||||
|
if (createdAt != null) {
|
||||||
|
originalWork.createdAt = createdAt
|
||||||
|
entityManager.flush()
|
||||||
|
}
|
||||||
|
return originalWork
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package kr.co.vividnext.sodalive.legacy
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.test.annotation.DirtiesContext
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
||||||
|
import org.springframework.web.multipart.MultipartException
|
||||||
|
import org.springframework.web.multipart.MultipartResolver
|
||||||
|
import org.springframework.web.servlet.DispatcherServlet
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:legacy-soda-exception-http-contract;" +
|
||||||
|
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
@Import(LegacySodaExceptionHttpStatusContractTest.TestLegacyController::class)
|
||||||
|
class LegacySodaExceptionHttpStatusContractTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
|
||||||
|
private lateinit var multipartResolver: MultipartResolver
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy route의 SodaException은 기존 HTTP 200 오류 envelope를 유지한다")
|
||||||
|
fun shouldPreserveLegacySodaExceptionHttp200() {
|
||||||
|
mockMvc.perform(get("/legacy-ai-character-admin-test").with(user("admin").roles("ADMIN")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy route는 httpStatus가 있는 SodaException도 HTTP 200 오류 envelope를 유지한다")
|
||||||
|
fun shouldPreserveLegacySodaExceptionHttp200WhenExceptionHasHttpStatus() {
|
||||||
|
mockMvc.perform(get("/legacy-ai-character-admin-test/conflict").with(user("admin").roles("ADMIN")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("admin ai-character multipart 크기 오류는 handler 선택 전에도 HTTP 400을 반환한다")
|
||||||
|
fun shouldReturnBadRequestForAiCharacterAdminMaxUploadSizeExceeded() {
|
||||||
|
givenMultipartResolutionFailure(MaxUploadSizeExceededException(1L))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/admin/ai-characters/upload-size-test")
|
||||||
|
.with(user("admin").roles("ADMIN"))
|
||||||
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("파일용량은 최대 1024MB까지 저장할 수 있습니다."))
|
||||||
|
|
||||||
|
Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy multipart 형식 오류는 handler 선택 전에도 기존 HTTP 200 unknown 오류를 유지한다")
|
||||||
|
fun shouldPreserveLegacyUnknownMessageForMalformedMultipartBeforeHandlerSelection() {
|
||||||
|
givenMultipartResolutionFailure(MultipartException("malformed multipart"))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/legacy-ai-character-admin-test/upload")
|
||||||
|
.with(user("admin").roles("ADMIN"))
|
||||||
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."))
|
||||||
|
|
||||||
|
Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun givenMultipartResolutionFailure(exception: MultipartException) {
|
||||||
|
Mockito.`when`(multipartResolver.isMultipart(Mockito.any(HttpServletRequest::class.java))).thenReturn(true)
|
||||||
|
Mockito.`when`(multipartResolver.resolveMultipart(Mockito.any(HttpServletRequest::class.java))).thenThrow(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/legacy-ai-character-admin-test")
|
||||||
|
class TestLegacyController {
|
||||||
|
@GetMapping
|
||||||
|
fun legacy(): ApiResponse<String> {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/conflict")
|
||||||
|
fun legacyWithHttpStatus(): ApiResponse<String> {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request", httpStatus = HttpStatus.CONFLICT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AdminJsonRequestParserTest {
|
||||||
|
private val parser = AdminJsonRequestParser(ObjectMapper())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multipart request JSON은 명시적 null key를 누락으로 보지 않는다")
|
||||||
|
fun shouldKeepExplicitNullRequiredKey() {
|
||||||
|
val node = parser.parseRequiredObject(
|
||||||
|
rawJson = """{"name":null,"tags":[]}""",
|
||||||
|
requiredKeys = setOf("name", "tags")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(node.has("name"))
|
||||||
|
assertTrue(node.get("name").isNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multipart request JSON은 필수 key 누락을 errorProperty로 반환한다")
|
||||||
|
fun shouldRejectMissingRequiredKey() {
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
parser.parseRequiredObject(rawJson = """{"name":"Soda"}""", requiredKeys = setOf("name", "tags"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("tags", exception.errorProperty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multipart request JSON 뒤에 다른 root가 이어지면 거부한다")
|
||||||
|
fun shouldRejectTrailingJsonRoot() {
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
parser.parseRequiredObject(
|
||||||
|
rawJson = """{"name":"Soda"}{"name":"Pop"}""",
|
||||||
|
requiredKeys = setOf("name")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("request", exception.errorProperty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multipart request JSON 뒤에 garbage가 이어지면 거부한다")
|
||||||
|
fun shouldRejectTrailingGarbage() {
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
parser.parseRequiredObject(
|
||||||
|
rawJson = """{"name":"Soda"} trailing""",
|
||||||
|
requiredKeys = setOf("name")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("request", exception.errorProperty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("빈 multipart request JSON은 잘못된 요청으로 거부한다")
|
||||||
|
fun shouldRejectBlankJson() {
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
parser.parseRequiredObject(rawJson = " ", requiredKeys = setOf("name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("request", exception.errorProperty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 JsonNode body도 같은 필수 key 계약을 사용한다")
|
||||||
|
fun shouldParseJsonNodeWithSameRequiredKeyPolicy() {
|
||||||
|
val node = ObjectMapper().readTree("""{"comment":null}""")
|
||||||
|
|
||||||
|
val parsed = parser.parseRequiredObject(node = node, requiredKeys = setOf("comment"))
|
||||||
|
|
||||||
|
assertTrue(parsed.get("comment").isNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 JsonNode body도 필수 key가 없으면 errorProperty로 반환한다")
|
||||||
|
fun shouldRejectJsonNodeMissingRequiredKey() {
|
||||||
|
val node = ObjectMapper().readTree("""{"comment":"hello"}""")
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
parser.parseRequiredObject(node = node, requiredKeys = setOf("comment", "author"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("author", exception.errorProperty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser
|
||||||
|
import com.fasterxml.jackson.databind.JsonMappingException
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertAll
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||||
|
import org.springframework.mock.http.MockHttpInputMessage
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||||
|
|
||||||
|
class AiCharacterAdminExceptionHandlerTest {
|
||||||
|
private val handler = AiCharacterAdminExceptionHandler(
|
||||||
|
langContext = LangContext(),
|
||||||
|
messageSource = SodaMessageSource()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SodaException은 지정 HTTP status와 errorProperty를 ApiResponse로 반환한다")
|
||||||
|
fun shouldReturnConfiguredStatusAndErrorProperty() {
|
||||||
|
listOf(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
HttpStatus.BAD_GATEWAY
|
||||||
|
).forEach { status ->
|
||||||
|
val response = handler.handleSodaException(
|
||||||
|
SodaException(
|
||||||
|
messageKey = "common.error.invalid_request",
|
||||||
|
errorProperty = "characterId",
|
||||||
|
httpStatus = status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(status, response.statusCode) },
|
||||||
|
{ assertEquals(false, response.body?.success) },
|
||||||
|
{ assertEquals("characterId", response.body?.errorProperty) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HTTP status가 없는 SodaException은 관리자 경계에서 400으로 응답한다")
|
||||||
|
fun shouldDefaultSodaExceptionToBadRequest() {
|
||||||
|
val response = handler.handleSodaException(SodaException(messageKey = "common.error.invalid_request"))
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||||
|
{ assertEquals(false, response.body?.success) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("알 수 없는 예외는 500 ApiResponse로 응답한다")
|
||||||
|
fun shouldReturnInternalServerErrorForUnknownException() {
|
||||||
|
val response = handler.handleException(IllegalStateException("sensitive-body"))
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) },
|
||||||
|
{ assertEquals(false, response.body?.success) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("잘못된 request parameter는 400 errorProperty를 반환한다")
|
||||||
|
fun shouldReturnErrorPropertyForMissingRequestParameter() {
|
||||||
|
val response = handler.handleBadRequestException(MissingServletRequestParameterException("page", "Int"))
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||||
|
{ assertEquals(false, response.body?.success) },
|
||||||
|
{ assertEquals("page", response.body?.errorProperty) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("누락된 multipart part는 400 errorProperty를 반환한다")
|
||||||
|
fun shouldReturnErrorPropertyForMissingRequestPart() {
|
||||||
|
val response = handler.handleBadRequestException(MissingServletRequestPartException("image"))
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||||
|
{ assertEquals(false, response.body?.success) },
|
||||||
|
{ assertEquals("image", response.body?.errorProperty) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("읽을 수 없는 JSON body는 Jackson path의 field를 errorProperty로 반환한다")
|
||||||
|
fun shouldReturnJsonBodyFieldNameForUnreadableBody() {
|
||||||
|
val cause = JsonMappingException.from(null as JsonParser?, "missing content")
|
||||||
|
.also { it.prependPath(AdminCommentUpdateRequest::class.java, "content") }
|
||||||
|
val exception = HttpMessageNotReadableException("bad request", cause, MockHttpInputMessage(ByteArray(0)))
|
||||||
|
|
||||||
|
val response = handler.handleBadRequestException(exception)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||||
|
{ assertEquals(false, response.body?.success) },
|
||||||
|
{ assertEquals("content", response.body?.errorProperty) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("읽을 수 없는 JSON body에 Jackson field path가 없으면 request를 errorProperty로 반환한다")
|
||||||
|
fun shouldFallbackToRequestForUnreadableBodyWithoutJsonPath() {
|
||||||
|
val exception = HttpMessageNotReadableException("bad request", MockHttpInputMessage(ByteArray(0)))
|
||||||
|
|
||||||
|
val response = handler.handleBadRequestException(exception)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) },
|
||||||
|
{ assertEquals(false, response.body?.success) },
|
||||||
|
{ assertEquals("request", response.body?.errorProperty) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginController
|
||||||
|
import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginService
|
||||||
|
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberToken
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.test.context.TestPropertySource
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
|
@WebMvcTest(
|
||||||
|
controllers = [
|
||||||
|
AdminMemberLoginController::class,
|
||||||
|
AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(
|
||||||
|
SecurityConfig::class,
|
||||||
|
JwtAuthenticationEntryPoint::class,
|
||||||
|
JwtAccessDeniedHandler::class,
|
||||||
|
AiCharacterAdminAuthenticationEntryPoint::class,
|
||||||
|
AiCharacterAdminAccessDeniedHandler::class,
|
||||||
|
AiCharacterAdminExceptionHandler::class,
|
||||||
|
AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class,
|
||||||
|
AdminMemberLoginService::class,
|
||||||
|
TokenProvider::class,
|
||||||
|
LangContext::class,
|
||||||
|
SodaMessageSource::class
|
||||||
|
)
|
||||||
|
@TestPropertySource(
|
||||||
|
properties = [
|
||||||
|
"jwt.secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||||
|
"jwt.token-validity-in-seconds=3600"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var adminMemberRepository: AdminMemberRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var memberTokenRepository: MemberTokenRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var passwordEncoder: PasswordEncoder
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 로그인으로 받은 실제 JWT는 신규 관리자 API 인증에 사용된다")
|
||||||
|
fun shouldCallAiCharacterAdminApiWithTokenFromAdminLogin() {
|
||||||
|
val member = Member(email = "admin@test.com", password = "encoded-password", nickname = "admin", role = MemberRole.ADMIN)
|
||||||
|
.apply { id = 1L }
|
||||||
|
var savedToken: MemberToken? = null
|
||||||
|
Mockito.`when`(adminMemberRepository.findByEmail("admin@test.com")).thenReturn(member)
|
||||||
|
Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(Optional.of(member))
|
||||||
|
Mockito.`when`(memberTokenRepository.findById(Mockito.eq(1L))).thenAnswer { Optional.ofNullable(savedToken) }
|
||||||
|
Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as MemberToken).also { savedToken = it }
|
||||||
|
}
|
||||||
|
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||||
|
|
||||||
|
val loginResult = mockMvc.perform(
|
||||||
|
post("/admin/member/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"email":"admin@test.com","password":"password"}""")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.role").value("ADMIN"))
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
val token = Regex(""""token":"([^"]+)"""")
|
||||||
|
.find(loginResult.response.contentAsString)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data").value("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("CONTENT_MANAGER 로그인으로 받은 실제 JWT는 신규 관리자 API에서 403으로 거부된다")
|
||||||
|
fun shouldRejectContentManagerTokenFromAdminLoginForAiCharacterAdminApi() {
|
||||||
|
val member = Member(
|
||||||
|
email = "content@test.com",
|
||||||
|
password = "encoded-password",
|
||||||
|
nickname = "content-manager",
|
||||||
|
role = MemberRole.CONTENT_MANAGER
|
||||||
|
).apply { id = 2L }
|
||||||
|
var savedToken: MemberToken? = null
|
||||||
|
Mockito.`when`(adminMemberRepository.findByEmail("content@test.com")).thenReturn(member)
|
||||||
|
Mockito.`when`(memberRepository.findById(Mockito.eq(2L))).thenReturn(Optional.of(member))
|
||||||
|
Mockito.`when`(memberTokenRepository.findById(Mockito.eq(2L))).thenAnswer { Optional.ofNullable(savedToken) }
|
||||||
|
Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation ->
|
||||||
|
(invocation.arguments[0] as MemberToken).also { savedToken = it }
|
||||||
|
}
|
||||||
|
Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true)
|
||||||
|
|
||||||
|
val loginResult = mockMvc.perform(
|
||||||
|
post("/admin/member/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""{"email":"content@test.com","password":"password"}""")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.role").value("CONTENT_MANAGER"))
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
val token = Regex(""""token":"([^"]+)"""")
|
||||||
|
.find(loginResult.response.contentAsString)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token"))
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/ai-characters/login-jwt-test")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class TestAiCharacterAdminController {
|
||||||
|
@GetMapping
|
||||||
|
fun ok(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest
|
||||||
|
import org.hamcrest.Matchers.containsString
|
||||||
|
import org.hamcrest.Matchers.not
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.util.stream.Stream
|
||||||
|
|
||||||
|
@WebMvcTest(
|
||||||
|
controllers = [
|
||||||
|
AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class,
|
||||||
|
AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(
|
||||||
|
SecurityConfig::class,
|
||||||
|
JwtAuthenticationEntryPoint::class,
|
||||||
|
JwtAccessDeniedHandler::class,
|
||||||
|
AiCharacterAdminAuthenticationEntryPoint::class,
|
||||||
|
AiCharacterAdminAccessDeniedHandler::class,
|
||||||
|
AiCharacterAdminExceptionHandler::class,
|
||||||
|
AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class,
|
||||||
|
AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class,
|
||||||
|
LangContext::class,
|
||||||
|
SodaMessageSource::class
|
||||||
|
)
|
||||||
|
class AiCharacterAdminSecurityIntegrationTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("/admin/ai-characters 전용 API는 ADMIN 권한이면 통과한다")
|
||||||
|
fun shouldAllowAdminRole() {
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/test").with(user("admin").roles("ADMIN")))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data").value("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 JWT 인증 결과가 ADMIN이면 신규 관리자 API를 호출할 수 있다")
|
||||||
|
fun shouldAllowAdminJwtAuthentication() {
|
||||||
|
val admin = Member(email = "admin@test.com", password = "password", nickname = "admin", role = MemberRole.ADMIN)
|
||||||
|
.apply { id = 1L }
|
||||||
|
val authentication = UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(admin),
|
||||||
|
"admin-token",
|
||||||
|
MemberAdapter(admin).authorities
|
||||||
|
)
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("admin-token")).thenReturn(true)
|
||||||
|
Mockito.`when`(tokenProvider.getAuthentication("admin-token")).thenReturn(authentication)
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer admin-token"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("/admin/ai-characters 전용 API는 비로그인을 401 ApiResponse로 거부한다")
|
||||||
|
fun shouldRejectAnonymousWithJson401() {
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/test").with(anonymous()))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("/admin/ai-characters 전용 API는 ADMIN 외 권한을 403 ApiResponse로 거부한다")
|
||||||
|
fun shouldRejectNonAdminWithJson403() {
|
||||||
|
Stream.of("USER", "CREATOR", "AGENT", "CONTENT_MANAGER").forEach { role ->
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/test").with(user(role.lowercase()).roles(role)))
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ADMIN 외 권한은 request body 역직렬화 전에 403으로 거부된다")
|
||||||
|
fun shouldRejectNonAdminBeforeRequestBodyBinding() {
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/admin/ai-characters/test/body")
|
||||||
|
.with(user("user").roles("USER"))
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("{")
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("잘못된 JWT는 401 ApiResponse로 거부한다")
|
||||||
|
fun shouldRejectInvalidJwtWithJson401() {
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
|
||||||
|
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer invalid-token"))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("/admin/ai-characters 인접 prefix는 신규 관리자 오류 handler 대상이 아니다")
|
||||||
|
fun shouldNotUseAdminHandlerForAdjacentPrefix() {
|
||||||
|
mockMvc.perform(get("/admin/ai-characters-shadow/test").with(anonymous()))
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
.andExpect(content().string(not(containsString("success"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("/admin/ai-characters 전용 예외는 지정 HTTP status와 errorProperty를 응답한다")
|
||||||
|
fun shouldUseAdminExceptionBoundary() {
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/test/conflict").with(user("admin").roles("ADMIN")))
|
||||||
|
.andExpect(status().isConflict)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.errorProperty").value("characterId"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("/admin/ai-characters 전용 예외는 400/404/500/502 status를 보존한다")
|
||||||
|
fun shouldUseAdminExceptionBoundaryForCommonStatuses() {
|
||||||
|
mapOf(
|
||||||
|
"bad-request" to HttpStatus.BAD_REQUEST,
|
||||||
|
"not-found" to HttpStatus.NOT_FOUND,
|
||||||
|
"server-error" to HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"bad-gateway" to HttpStatus.BAD_GATEWAY
|
||||||
|
).forEach { (path, status) ->
|
||||||
|
mockMvc.perform(get("/admin/ai-characters/test/$path").with(user("admin").roles("ADMIN")))
|
||||||
|
.andExpect(status().`is`(status.value()))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/ai-characters/test")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class TestAiCharacterAdminController {
|
||||||
|
@GetMapping
|
||||||
|
fun ok(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@PostMapping("/body")
|
||||||
|
fun body(@RequestBody request: AdminCommentUpdateRequest): ApiResponse<String> = ApiResponse.ok(request.content)
|
||||||
|
|
||||||
|
@GetMapping("/conflict")
|
||||||
|
fun conflict(): ApiResponse<String> {
|
||||||
|
throw SodaException(
|
||||||
|
messageKey = "common.error.invalid_request",
|
||||||
|
errorProperty = "characterId",
|
||||||
|
httpStatus = HttpStatus.CONFLICT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/bad-request")
|
||||||
|
fun badRequest(): ApiResponse<String> = throwStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@GetMapping("/not-found")
|
||||||
|
fun notFound(): ApiResponse<String> = throwStatus(HttpStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@GetMapping("/server-error")
|
||||||
|
fun serverError(): ApiResponse<String> = throwStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@GetMapping("/bad-gateway")
|
||||||
|
fun badGateway(): ApiResponse<String> = throwStatus(HttpStatus.BAD_GATEWAY)
|
||||||
|
|
||||||
|
private fun throwStatus(status: HttpStatus): ApiResponse<String> {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request", httpStatus = status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/ai-characters-shadow/test")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class TestAdjacentAdminController {
|
||||||
|
@GetMapping
|
||||||
|
fun ok(): ApiResponse<String> = ApiResponse.ok("shadow")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminMutationResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageResponse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AdminPagePolicyTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 목록 page와 size 기본값을 정규화한다")
|
||||||
|
fun shouldNormalizeDefaultPageRequest() {
|
||||||
|
val request = AdminPagePolicy.normalize(page = null, size = null)
|
||||||
|
|
||||||
|
assertEquals(0, request.page)
|
||||||
|
assertEquals(20, request.size)
|
||||||
|
assertEquals(0, request.offset)
|
||||||
|
assertEquals(20, request.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 목록 page와 size 경계값을 정규화한다")
|
||||||
|
fun shouldNormalizePageRequestBoundaries() {
|
||||||
|
val lowerBound = AdminPagePolicy.normalize(page = -1, size = 0)
|
||||||
|
val upperBound = AdminPagePolicy.normalize(page = 2, size = 51)
|
||||||
|
|
||||||
|
assertEquals(0, lowerBound.page)
|
||||||
|
assertEquals(20, lowerBound.size)
|
||||||
|
assertEquals(2, upperBound.page)
|
||||||
|
assertEquals(50, upperBound.size)
|
||||||
|
assertEquals(100, upperBound.offset)
|
||||||
|
assertEquals(50, upperBound.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 목록 page 0과 size 1/20/50 유효 경계값을 유지한다")
|
||||||
|
fun shouldKeepValidPageRequestBoundaries() {
|
||||||
|
listOf(1, 20, 50).forEach { size ->
|
||||||
|
val request = AdminPagePolicy.normalize(page = 0, size = size)
|
||||||
|
|
||||||
|
assertEquals(0, request.page)
|
||||||
|
assertEquals(size, request.size)
|
||||||
|
assertEquals(0, request.offset)
|
||||||
|
assertEquals(size.toLong(), request.limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 page 응답은 hasNext를 계산한다")
|
||||||
|
fun shouldCalculateHasNextForPageResponse() {
|
||||||
|
val firstPage = AdminPageResponse.of(totalCount = 3, content = listOf(1, 2), page = 0, size = 2)
|
||||||
|
val lastPage = AdminPageResponse.of(totalCount = 3, content = listOf(3), page = 1, size = 2)
|
||||||
|
|
||||||
|
assertTrue(firstPage.hasNext)
|
||||||
|
assertFalse(lastPage.hasNext)
|
||||||
|
assertEquals(listOf(1, 2), firstPage.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 page 응답은 Int overflow 없이 hasNext를 계산한다")
|
||||||
|
fun shouldCalculateHasNextWithoutIntOverflow() {
|
||||||
|
val response = AdminPageResponse.of(
|
||||||
|
totalCount = Long.MAX_VALUE,
|
||||||
|
content = emptyList<Int>(),
|
||||||
|
page = Int.MAX_VALUE,
|
||||||
|
size = Int.MAX_VALUE
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 공통 mutation 응답과 댓글 수정 요청 DTO 계약을 유지한다")
|
||||||
|
fun shouldKeepAdminMutationDtoContract() {
|
||||||
|
val mutationResponse = AdminMutationResponse(id = 1L, isActive = true)
|
||||||
|
val commentUpdateRequest = AdminCommentUpdateRequest(content = "updated")
|
||||||
|
|
||||||
|
assertEquals(1L, mutationResponse.id)
|
||||||
|
assertTrue(mutationResponse.isActive)
|
||||||
|
assertEquals("updated", commentUpdateRequest.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.admin.aicharacter.application
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||||
|
|
||||||
|
@ExtendWith(OutputCaptureExtension::class)
|
||||||
|
class AiCharacterAdminAuditLoggerTest {
|
||||||
|
private val auditLogger = AiCharacterAdminAuditLogger()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("global 원작 mutation audit은 character field null을 허용한다")
|
||||||
|
fun shouldAllowGlobalOriginalWorkAuditContext(output: CapturedOutput) {
|
||||||
|
auditLogger.logSuccess(
|
||||||
|
context = AiCharacterAdminAuditContext.globalOriginalWork(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
action = AiCharacterAdminAuditAction.CREATE,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(output.out.contains("aiCharacterAdminAudit result=SUCCESS adminMemberId=1"))
|
||||||
|
assertTrue(output.out.contains("characterId=null"))
|
||||||
|
assertTrue(output.out.contains("creatorMemberId=null"))
|
||||||
|
assertTrue(output.out.contains("action=CREATE"))
|
||||||
|
assertTrue(output.out.contains("resourceType=ORIGINAL_WORK"))
|
||||||
|
assertTrue(output.out.contains("resourceId=10"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("character-scoped mutation audit은 character와 creator field를 명시한다")
|
||||||
|
fun shouldAllowCharacterScopedAuditContext(output: CapturedOutput) {
|
||||||
|
auditLogger.logFailure(
|
||||||
|
context = AiCharacterAdminAuditContext.characterScoped(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
characterId = 2L,
|
||||||
|
creatorMemberId = 3L,
|
||||||
|
action = AiCharacterAdminAuditAction.UPDATE,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.CHARACTER,
|
||||||
|
resourceId = 2L
|
||||||
|
),
|
||||||
|
exception = IllegalStateException("failed-sensitive-body")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(output.out.contains("result=FAILURE"))
|
||||||
|
assertTrue(output.out.contains("characterId=2"))
|
||||||
|
assertTrue(output.out.contains("creatorMemberId=3"))
|
||||||
|
assertTrue(output.out.contains("action=UPDATE"))
|
||||||
|
assertTrue(output.out.contains("resourceType=CHARACTER"))
|
||||||
|
assertTrue(output.out.contains("resourceId=2"))
|
||||||
|
assertTrue(output.out.contains("error=IllegalStateException"))
|
||||||
|
assertFalse(output.out.contains("failed-sensitive-body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("원작 배정 audit은 global context로 만들 수 없다")
|
||||||
|
fun shouldRejectAssignmentAuditInGlobalOriginalWorkContext() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
AiCharacterAdminAuditContext.globalOriginalWork(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
action = AiCharacterAdminAuditAction.ASSIGN,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("global 원작 audit은 create/update/delete action만 허용한다")
|
||||||
|
fun shouldRejectNonMutationActionInGlobalOriginalWorkContext() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
AiCharacterAdminAuditContext.globalOriginalWork(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
action = AiCharacterAdminAuditAction.READ,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("원작 배정 audit은 캐릭터별 ORIGINAL_WORK_CHARACTER context만 허용한다")
|
||||||
|
fun shouldRejectAssignmentAuditWithoutOriginalWorkCharacterResourceType() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
AiCharacterAdminAuditContext.characterScoped(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
characterId = 2L,
|
||||||
|
creatorMemberId = 3L,
|
||||||
|
action = AiCharacterAdminAuditAction.ASSIGN,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ORIGINAL_WORK_CHARACTER resource는 assign/unassign action만 허용한다")
|
||||||
|
fun shouldRejectOriginalWorkCharacterResourceForNonAssignmentAction() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
AiCharacterAdminAuditContext.characterScoped(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
characterId = 2L,
|
||||||
|
creatorMemberId = 3L,
|
||||||
|
action = AiCharacterAdminAuditAction.UPDATE,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("원작 배정 audit은 creator member id를 필수로 요구한다")
|
||||||
|
fun shouldRejectAssignmentWithoutCreatorMemberId() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
AiCharacterAdminAuditContext.characterScoped(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
characterId = 2L,
|
||||||
|
creatorMemberId = null,
|
||||||
|
action = AiCharacterAdminAuditAction.ASSIGN,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("global 원작 resource는 character-scoped context로 만들 수 없다")
|
||||||
|
fun shouldRejectGlobalOriginalWorkResourceInCharacterScopedContext() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
AiCharacterAdminAuditContext.characterScoped(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
characterId = 2L,
|
||||||
|
creatorMemberId = 3L,
|
||||||
|
action = AiCharacterAdminAuditAction.UPDATE,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("원작 해제가 아닌 character-scoped audit은 creator member id가 필요하다")
|
||||||
|
fun shouldRejectMissingCreatorMemberIdOutsideUnassignment() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
AiCharacterAdminAuditContext.characterScoped(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
characterId = 2L,
|
||||||
|
creatorMemberId = null,
|
||||||
|
action = AiCharacterAdminAuditAction.UPDATE,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.CHARACTER,
|
||||||
|
resourceId = 2L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("원작 해제 audit은 creator member id null을 허용한다")
|
||||||
|
fun shouldAllowMissingCreatorMemberIdForUnassignment() {
|
||||||
|
assertDoesNotThrow {
|
||||||
|
AiCharacterAdminAuditContext.characterScoped(
|
||||||
|
adminMemberId = 1L,
|
||||||
|
characterId = 2L,
|
||||||
|
creatorMemberId = null,
|
||||||
|
action = AiCharacterAdminAuditAction.UNASSIGN,
|
||||||
|
resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER,
|
||||||
|
resourceId = 10L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("audit context는 factory 검증을 우회하는 public copy를 노출하지 않는다")
|
||||||
|
fun shouldNotExposePublicCopyMethod() {
|
||||||
|
assertFalse(AiCharacterAdminAuditContext::class.java.methods.any { method -> method.name == "copy" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("audit resource type은 원작 배정과 댓글/카테고리/공지/FanTalk 답글을 표현한다")
|
||||||
|
fun shouldExposeRequiredAuditResourceTypes() {
|
||||||
|
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER))
|
||||||
|
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_COMMENT))
|
||||||
|
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_CATEGORY))
|
||||||
|
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.FAN_TALK_REPLY))
|
||||||
|
assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CHANNEL_NOTICE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("audit action은 콘텐츠 고정까지 표현한다")
|
||||||
|
fun shouldExposePinAuditAction() {
|
||||||
|
assertTrue(AiCharacterAdminAuditAction.values().contains(AiCharacterAdminAuditAction.PIN))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
import javax.persistence.TypedQuery
|
||||||
|
|
||||||
|
@DataJpaTest(properties = ["spring.cache.type=none"])
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class DefaultAiCharacterPersistenceAdapterTest @Autowired constructor(
|
||||||
|
private val entityManager: TestEntityManager,
|
||||||
|
jpaEntityManager: EntityManager
|
||||||
|
) {
|
||||||
|
private val adapter = DefaultAiCharacterPersistenceAdapter(jpaEntityManager)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 관리자 대상은 ChatCharacter와 AI creator Member를 함께 반환한다")
|
||||||
|
fun shouldFindAiCharacterAdminTarget() {
|
||||||
|
val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = true)
|
||||||
|
val character = persistCharacter(creator = creator, isActive = true)
|
||||||
|
|
||||||
|
val target = adapter.findAdminTarget(character.id!!)
|
||||||
|
|
||||||
|
assertEquals(character.id, target?.characterId)
|
||||||
|
assertEquals(creator.id, target?.creatorMemberId)
|
||||||
|
assertEquals(true, target?.characterIsActive)
|
||||||
|
assertEquals(true, target?.creatorMemberIsActive)
|
||||||
|
assertEquals(MemberRole.CREATOR, target?.creatorRole)
|
||||||
|
assertEquals(MemberKind.AI_CHARACTER, target?.memberKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("존재하지 않는 캐릭터는 관리자 대상이 아니다")
|
||||||
|
fun shouldReturnNullForMissingCharacter() {
|
||||||
|
assertNull(adapter.findAdminTarget(-1L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("creator Member가 없으면 관리자 대상이 아니다")
|
||||||
|
fun shouldReturnNullWhenCreatorMemberIsMissing() {
|
||||||
|
val entityManager = Mockito.mock(EntityManager::class.java)
|
||||||
|
val query = Mockito.mock(TypedQuery::class.java) as TypedQuery<ChatCharacter>
|
||||||
|
Mockito.`when`(entityManager.createQuery(Mockito.anyString(), Mockito.eq(ChatCharacter::class.java)))
|
||||||
|
.thenReturn(query)
|
||||||
|
Mockito.`when`(query.setParameter("characterId", 1L)).thenReturn(query)
|
||||||
|
Mockito.`when`(query.singleResult).thenReturn(characterWithoutCreator())
|
||||||
|
|
||||||
|
assertNull(DefaultAiCharacterPersistenceAdapter(entityManager).findAdminTarget(1L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("creator Member가 AI 캐릭터가 아니면 관리자 대상이 아니다")
|
||||||
|
fun shouldIgnoreHumanCreatorMember() {
|
||||||
|
val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.HUMAN, isActive = true)
|
||||||
|
val character = persistCharacter(creator = creator, isActive = true)
|
||||||
|
|
||||||
|
assertNull(adapter.findAdminTarget(character.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("비활성 캐릭터와 creator는 조회 대상에 포함하되 상태를 반환한다")
|
||||||
|
fun shouldReturnInactiveStateForExistingTarget() {
|
||||||
|
val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = false)
|
||||||
|
val character = persistCharacter(creator = creator, isActive = false)
|
||||||
|
|
||||||
|
val target = adapter.findAdminTarget(character.id!!)
|
||||||
|
|
||||||
|
assertEquals(false, target?.characterIsActive)
|
||||||
|
assertEquals(false, target?.creatorMemberIsActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistMember(role: MemberRole, memberKind: MemberKind, isActive: Boolean): Member {
|
||||||
|
return entityManager.persistAndFlush(
|
||||||
|
Member(
|
||||||
|
email = null,
|
||||||
|
password = "",
|
||||||
|
nickname = "creator-${System.nanoTime()}",
|
||||||
|
role = role,
|
||||||
|
memberKind = memberKind,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistCharacter(creator: Member?, isActive: Boolean): ChatCharacter {
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "uuid-${System.nanoTime()}",
|
||||||
|
name = "Soda",
|
||||||
|
description = "desc",
|
||||||
|
systemPrompt = "prompt",
|
||||||
|
characterType = CharacterType.Character,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
character.creatorMember = creator
|
||||||
|
return entityManager.persistAndFlush(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun characterWithoutCreator(): ChatCharacter {
|
||||||
|
return ChatCharacter(
|
||||||
|
characterUUID = "uuid-${System.nanoTime()}",
|
||||||
|
name = "Soda",
|
||||||
|
description = "desc",
|
||||||
|
systemPrompt = "prompt",
|
||||||
|
characterType = CharacterType.Character,
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.aicharacter.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget
|
||||||
|
import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort
|
||||||
|
import org.junit.jupiter.api.Assertions.assertAll
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
|
class AiCharacterAdminTargetResolverTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("존재하지 않는 관리자 대상 캐릭터는 404로 거부한다")
|
||||||
|
fun shouldRejectMissingTarget() {
|
||||||
|
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = null))
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
resolver.resolveActiveTarget(characterId = 1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals("characterId", exception.errorProperty) },
|
||||||
|
{ assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 creator가 아니면 404로 거부한다")
|
||||||
|
fun shouldRejectNonAiCreatorTargetAsMissing() {
|
||||||
|
listOf(
|
||||||
|
target(creatorRole = MemberRole.USER),
|
||||||
|
target(memberKind = MemberKind.HUMAN)
|
||||||
|
).forEach { target ->
|
||||||
|
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target))
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
resolver.resolveActiveTarget(characterId = 1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals("characterId", exception.errorProperty) },
|
||||||
|
{ assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("생성/수정 대상 해석은 비활성 캐릭터 또는 creator를 409로 거부한다")
|
||||||
|
fun shouldRejectInactiveTargetForMutation() {
|
||||||
|
listOf(
|
||||||
|
target(isCharacterActive = false, isCreatorActive = true),
|
||||||
|
target(isCharacterActive = true, isCreatorActive = false)
|
||||||
|
).forEach { target ->
|
||||||
|
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target))
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
resolver.resolveActiveTarget(characterId = 1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals("characterId", exception.errorProperty) },
|
||||||
|
{ assertEquals(HttpStatus.CONFLICT, exception.httpStatus) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("삭제와 상태 조회는 비활성 관리자 대상을 반환한다")
|
||||||
|
fun shouldReturnInactiveTargetForStatusOrDelete() {
|
||||||
|
val target = target(isCharacterActive = false, isCreatorActive = false)
|
||||||
|
val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target))
|
||||||
|
|
||||||
|
val resolved = resolver.resolveExistingTarget(characterId = 1L)
|
||||||
|
|
||||||
|
assertEquals(target, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("대상 해석은 요청 characterId를 persistence port에 그대로 전달한다")
|
||||||
|
fun shouldPassRequestedCharacterIdToPersistencePort() {
|
||||||
|
val port = FakeAiCharacterPersistencePort(target = target(characterId = 99L))
|
||||||
|
val resolver = AiCharacterAdminTargetResolver(port)
|
||||||
|
|
||||||
|
val resolved = resolver.resolveActiveTarget(characterId = 99L)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertEquals(99L, port.requestedCharacterId) },
|
||||||
|
{ assertEquals(99L, resolved.characterId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun target(
|
||||||
|
characterId: Long = 1L,
|
||||||
|
isCharacterActive: Boolean = true,
|
||||||
|
isCreatorActive: Boolean = true,
|
||||||
|
creatorRole: MemberRole = MemberRole.CREATOR,
|
||||||
|
memberKind: MemberKind = MemberKind.AI_CHARACTER
|
||||||
|
): AiCharacterAdminTarget {
|
||||||
|
return AiCharacterAdminTarget(
|
||||||
|
characterId = characterId,
|
||||||
|
creatorMemberId = 10L,
|
||||||
|
characterIsActive = isCharacterActive,
|
||||||
|
creatorMemberIsActive = isCreatorActive,
|
||||||
|
creatorRole = creatorRole,
|
||||||
|
memberKind = memberKind
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAiCharacterPersistencePort(
|
||||||
|
private val target: AiCharacterAdminTarget?
|
||||||
|
) : AiCharacterPersistencePort {
|
||||||
|
var requestedCharacterId: Long? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? {
|
||||||
|
requestedCharacterId = characterId
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.CountryContext
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -19,25 +23,18 @@ import org.junit.jupiter.api.Test
|
|||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
import org.springframework.boot.test.context.TestConfiguration
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
|
||||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.servlet.http.HttpServletResponse
|
|
||||||
|
|
||||||
@WebMvcTest(CreatorChannelCommunityController::class)
|
@WebMvcTest(CreatorChannelCommunityController::class)
|
||||||
@Import(CreatorChannelCommunityControllerTest.TestSecurityConfig::class)
|
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||||
class CreatorChannelCommunityControllerTest @Autowired constructor(
|
class CreatorChannelCommunityControllerTest @Autowired constructor(
|
||||||
private val mockMvc: MockMvc
|
private val mockMvc: MockMvc
|
||||||
) {
|
) {
|
||||||
@@ -53,22 +50,8 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
|
|||||||
@MockBean
|
@MockBean
|
||||||
private lateinit var sodaMessageSource: SodaMessageSource
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
@TestConfiguration
|
@MockBean
|
||||||
class TestSecurityConfig {
|
private lateinit var tokenProvider: TokenProvider
|
||||||
@Bean
|
|
||||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
|
||||||
return http
|
|
||||||
.csrf().disable()
|
|
||||||
.authorizeRequests()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
.and()
|
|
||||||
.exceptionHandling()
|
|
||||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
|
||||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
|
||||||
.and()
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("크리에이터 채널 커뮤니티 탭 조회는 비회원 요청을 거부한다")
|
@DisplayName("크리에이터 채널 커뮤니티 탭 조회는 비회원 요청을 거부한다")
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.CountryContext
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -15,25 +19,18 @@ import org.junit.jupiter.api.Test
|
|||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
import org.springframework.boot.test.context.TestConfiguration
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
|
||||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.servlet.http.HttpServletResponse
|
|
||||||
|
|
||||||
@WebMvcTest(CreatorChannelFanTalkController::class)
|
@WebMvcTest(CreatorChannelFanTalkController::class)
|
||||||
@Import(CreatorChannelFanTalkControllerTest.TestSecurityConfig::class)
|
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||||
class CreatorChannelFanTalkControllerTest @Autowired constructor(
|
class CreatorChannelFanTalkControllerTest @Autowired constructor(
|
||||||
private val mockMvc: MockMvc
|
private val mockMvc: MockMvc
|
||||||
) {
|
) {
|
||||||
@@ -49,22 +46,8 @@ class CreatorChannelFanTalkControllerTest @Autowired constructor(
|
|||||||
@MockBean
|
@MockBean
|
||||||
private lateinit var sodaMessageSource: SodaMessageSource
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
@TestConfiguration
|
@MockBean
|
||||||
class TestSecurityConfig {
|
private lateinit var tokenProvider: TokenProvider
|
||||||
@Bean
|
|
||||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
|
||||||
return http
|
|
||||||
.csrf().disable()
|
|
||||||
.authorizeRequests()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
.and()
|
|
||||||
.exceptionHandling()
|
|
||||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
|
||||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
|
||||||
.and()
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("크리에이터 채널 FanTalk 탭 조회는 비회원 요청을 거부한다")
|
@DisplayName("크리에이터 채널 FanTalk 탭 조회는 비회원 요청을 거부한다")
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.CountryContext
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -15,25 +19,18 @@ import org.junit.jupiter.api.Test
|
|||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
import org.springframework.boot.test.context.TestConfiguration
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
|
||||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.servlet.http.HttpServletResponse
|
|
||||||
|
|
||||||
@WebMvcTest(CreatorChannelSeriesController::class)
|
@WebMvcTest(CreatorChannelSeriesController::class)
|
||||||
@Import(CreatorChannelSeriesControllerTest.TestSecurityConfig::class)
|
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||||
class CreatorChannelSeriesControllerTest @Autowired constructor(
|
class CreatorChannelSeriesControllerTest @Autowired constructor(
|
||||||
private val mockMvc: MockMvc
|
private val mockMvc: MockMvc
|
||||||
) {
|
) {
|
||||||
@@ -49,22 +46,8 @@ class CreatorChannelSeriesControllerTest @Autowired constructor(
|
|||||||
@MockBean
|
@MockBean
|
||||||
private lateinit var sodaMessageSource: SodaMessageSource
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
@TestConfiguration
|
@MockBean
|
||||||
class TestSecurityConfig {
|
private lateinit var tokenProvider: TokenProvider
|
||||||
@Bean
|
|
||||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
|
||||||
return http
|
|
||||||
.csrf().disable()
|
|
||||||
.authorizeRequests()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
.and()
|
|
||||||
.exceptionHandling()
|
|
||||||
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
|
||||||
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
|
||||||
.and()
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("크리에이터 채널 시리즈 탭 조회는 비회원 요청을 거부한다")
|
@DisplayName("크리에이터 채널 시리즈 탭 조회는 비회원 요청을 거부한다")
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.common.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectListener
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService
|
||||||
|
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmSendListener
|
||||||
|
import kr.co.vividnext.sodalive.fcm.FcmService
|
||||||
|
import kr.co.vividnext.sodalive.fcm.PushTokenInfo
|
||||||
|
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationListener
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
|
|
||||||
|
@DataJpaTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"cloud.naver.papago-client-id=test-client-id",
|
||||||
|
"cloud.naver.papago-client-secret=test-client-secret"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(
|
||||||
|
AfterCommitExecutor::class,
|
||||||
|
QueryDslConfig::class,
|
||||||
|
AfterCommitEventBoundaryIntegrationTest.TestEventListener::class,
|
||||||
|
FcmSendListener::class,
|
||||||
|
LanguageDetectListener::class,
|
||||||
|
LanguageTranslationListener::class
|
||||||
|
)
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
class AfterCommitEventBoundaryIntegrationTest @Autowired constructor(
|
||||||
|
private val executor: AfterCommitExecutor,
|
||||||
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
|
private val testEventListener: TestEventListener,
|
||||||
|
transactionManager: PlatformTransactionManager
|
||||||
|
) {
|
||||||
|
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var fcmService: FcmService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var contentCommentRepository: AudioContentCommentRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var pushNotificationService: PushNotificationService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var audioContentRepository: AudioContentRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var chatCharacterRepository: ChatCharacterRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var characterCommentRepository: kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var creatorCheersRepository: CreatorCheersRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var seriesRepository: ContentSeriesRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var originalWorkRepository: OriginalWorkRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var categoryRepository: CategoryRepository
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var languageDetectionCacheService: LanguageDetectionCacheService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("direct callback은 실제 transaction commit 후 1회 실행된다")
|
||||||
|
fun shouldRunCallbackAfterRealTransactionCommit() {
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
executor.executeAfterCommit { count += 1 }
|
||||||
|
assertEquals(0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("direct callback은 실제 transaction rollback 후 실행되지 않는다")
|
||||||
|
fun shouldNotRunCallbackAfterRealTransactionRollback() {
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
executor.executeAfterCommit { count += 1 }
|
||||||
|
throw IllegalStateException("rollback")
|
||||||
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertEquals("rollback", e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("첫 attempt rollback 후 동일 command 재시도 commit 시 callback은 총 1회 실행된다")
|
||||||
|
fun shouldRunCallbackOnceWhenSameCommandCommitsOnRetryAfterRollback() {
|
||||||
|
var attemptCount = 0
|
||||||
|
var callbackCount = 0
|
||||||
|
val command = {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
attemptCount += 1
|
||||||
|
executor.executeAfterCommit { callbackCount += 1 }
|
||||||
|
if (attemptCount == 1) {
|
||||||
|
throw IllegalStateException("rollback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException::class.java) { command() }
|
||||||
|
assertEquals(0, callbackCount)
|
||||||
|
|
||||||
|
command()
|
||||||
|
|
||||||
|
assertEquals(2, attemptCount)
|
||||||
|
assertEquals(1, callbackCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 FCM과 언어 event listener는 AFTER_COMMIT 경계를 유지한다")
|
||||||
|
fun shouldKeepExistingEventListenersAfterCommit() {
|
||||||
|
assertEquals(
|
||||||
|
TransactionPhase.AFTER_COMMIT,
|
||||||
|
transactionalEventListener(FcmSendListener::class.java, "send", FcmEvent::class.java).phase
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
TransactionPhase.AFTER_COMMIT,
|
||||||
|
transactionalEventListener(
|
||||||
|
LanguageDetectListener::class.java,
|
||||||
|
"detectLanguage",
|
||||||
|
LanguageDetectEvent::class.java
|
||||||
|
).phase
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
TransactionPhase.AFTER_COMMIT,
|
||||||
|
transactionalEventListener(
|
||||||
|
LanguageTranslationListener::class.java,
|
||||||
|
"translationAfterCommit",
|
||||||
|
LanguageTranslationEvent::class.java
|
||||||
|
).phase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 event publish는 rollback되면 transactional listener를 실행하지 않는다")
|
||||||
|
fun shouldNotRunTransactionalEventListenerAfterRollback() {
|
||||||
|
testEventListener.messages.clear()
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
eventPublisher.publishEvent(TestEvent("rollback"))
|
||||||
|
throw IllegalStateException("rollback")
|
||||||
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertEquals("rollback", e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(emptyList<String>(), testEventListener.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 event publish는 commit 이후 transactional listener를 실행한다")
|
||||||
|
fun shouldRunTransactionalEventListenerAfterCommit() {
|
||||||
|
testEventListener.messages.clear()
|
||||||
|
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
eventPublisher.publishEvent(TestEvent("commit"))
|
||||||
|
assertEquals(emptyList<String>(), testEventListener.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(listOf("commit"), testEventListener.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 FCM event listener는 transaction commit 이후 실행된다")
|
||||||
|
fun shouldRunRealFcmEventListenerAfterCommit() {
|
||||||
|
val event = FcmEvent(
|
||||||
|
type = FcmEventType.CANCEL_LIVE,
|
||||||
|
title = "title",
|
||||||
|
message = "message",
|
||||||
|
pushTokens = listOf(PushTokenInfo(token = "token", deviceType = "ios", languageCode = "ko"))
|
||||||
|
)
|
||||||
|
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
eventPublisher.publishEvent(event)
|
||||||
|
Mockito.verifyNoInteractions(fcmService)
|
||||||
|
}
|
||||||
|
|
||||||
|
Mockito.verify(fcmService, Mockito.timeout(1000)).send(
|
||||||
|
tokens = listOf("token"),
|
||||||
|
title = "title",
|
||||||
|
message = "message",
|
||||||
|
container = "ios",
|
||||||
|
roomId = null,
|
||||||
|
messageId = null,
|
||||||
|
contentId = null,
|
||||||
|
creatorId = null,
|
||||||
|
auditionId = null,
|
||||||
|
deepLinkValue = null,
|
||||||
|
deepLinkId = null,
|
||||||
|
deepLinkCommentPostId = null,
|
||||||
|
chatType = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 언어 감지 event listener는 rollback되면 실행되지 않는다")
|
||||||
|
fun shouldNotRunRealLanguageDetectListenerAfterRollback() {
|
||||||
|
try {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
eventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(id = 9L, query = "hello", targetType = LanguageDetectTargetType.CHARACTER)
|
||||||
|
)
|
||||||
|
throw IllegalStateException("rollback")
|
||||||
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertEquals("rollback", e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
Mockito.verifyNoInteractions(chatCharacterRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 언어 감지 event listener는 commit 이후 실행된다")
|
||||||
|
fun shouldRunRealLanguageDetectListenerAfterCommit() {
|
||||||
|
Mockito.doReturn("ko").`when`(languageDetectionCacheService)
|
||||||
|
.detectWithCache(eqValue("hello"), eqValue("papago"), anyValue())
|
||||||
|
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
eventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = 9L,
|
||||||
|
query = "hello",
|
||||||
|
targetType = LanguageDetectTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Mockito.verifyNoInteractions(chatCharacterRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(chatCharacterRepository, Mockito.timeout(1000)).findById(eqValue(9L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("waitTransactionCommit=true 언어 번역 event는 commit 이후 scheduler를 호출한다")
|
||||||
|
fun shouldRunRealLanguageTranslationListenerAfterCommitWhenWaitingTransactionCommit() {
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
eventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = 11L,
|
||||||
|
targetType = LanguageTranslationTargetType.CHARACTER,
|
||||||
|
waitTransactionCommit = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Mockito.verifyNoInteractions(resourceTranslationJobScheduler)
|
||||||
|
}
|
||||||
|
|
||||||
|
Mockito.verify(resourceTranslationJobScheduler, Mockito.timeout(1000))
|
||||||
|
.scheduleResourceTranslations(LanguageTranslationTargetType.CHARACTER, 11L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transactionalEventListener(
|
||||||
|
type: Class<*>,
|
||||||
|
methodName: String,
|
||||||
|
eventType: Class<*>
|
||||||
|
): TransactionalEventListener {
|
||||||
|
return type.getDeclaredMethod(methodName, eventType).getAnnotation(TransactionalEventListener::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> eqValue(value: T): T {
|
||||||
|
return Mockito.eq(value) ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> anyValue(): T {
|
||||||
|
return Mockito.any<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TestEvent(val message: String)
|
||||||
|
|
||||||
|
class TestEventListener {
|
||||||
|
val messages = mutableListOf<String>()
|
||||||
|
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
fun handle(event: TestEvent) {
|
||||||
|
messages += event.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.common.application
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
|
|
||||||
|
class AfterCommitExecutorTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("트랜잭션이 없으면 direct callback을 즉시 1회 실행한다")
|
||||||
|
fun shouldRunCallbackImmediatelyWithoutTransaction() {
|
||||||
|
val executor = AfterCommitExecutor()
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
executor.executeAfterCommit { count += 1 }
|
||||||
|
|
||||||
|
assertEquals(1, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("트랜잭션이 없으면 direct callback 예외를 호출자에게 전파하지 않는다")
|
||||||
|
fun shouldNotPropagateCallbackExceptionWithoutTransaction() {
|
||||||
|
val executor = AfterCommitExecutor()
|
||||||
|
|
||||||
|
assertDoesNotThrow {
|
||||||
|
executor.executeAfterCommit { throw IllegalStateException("sensitive-body") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("트랜잭션 commit 이후 direct callback을 1회 실행한다")
|
||||||
|
fun shouldRunCallbackOnceAfterCommit() {
|
||||||
|
val executor = AfterCommitExecutor()
|
||||||
|
var count = 0
|
||||||
|
TransactionSynchronizationManager.initSynchronization()
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.executeAfterCommit { count += 1 }
|
||||||
|
TransactionSynchronizationManager.getSynchronizations().forEach { synchronization ->
|
||||||
|
synchronization.afterCommit()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("afterCommit callback 예외는 격리되어 이후 callback 실행을 막지 않는다")
|
||||||
|
fun shouldIsolateCallbackExceptionAfterCommit() {
|
||||||
|
val executor = AfterCommitExecutor()
|
||||||
|
var count = 0
|
||||||
|
TransactionSynchronizationManager.initSynchronization()
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.executeAfterCommit { throw IllegalStateException("sensitive-body") }
|
||||||
|
executor.executeAfterCommit { count += 1 }
|
||||||
|
|
||||||
|
assertDoesNotThrow {
|
||||||
|
TransactionSynchronizationManager.getSynchronizations().forEach { synchronization ->
|
||||||
|
synchronization.afterCommit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("트랜잭션 rollback이면 direct callback을 실행하지 않는다")
|
||||||
|
fun shouldNotRunCallbackBeforeCommit() {
|
||||||
|
val executor = AfterCommitExecutor()
|
||||||
|
var count = 0
|
||||||
|
TransactionSynchronizationManager.initSynchronization()
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.executeAfterCommit { count += 1 }
|
||||||
|
} finally {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1245,6 +1245,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId })
|
assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 데뷔 크리에이터는 종료된 라이브도 채널명이 있으면 라이브 데뷔로 인정한다")
|
||||||
|
fun shouldIncludeEndedLiveWithChannelNameInRecentDebutCreators() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val endedLiveCreator = saveMember("ended-live-debut", MemberRole.CREATOR)
|
||||||
|
val blankChannelCreator = saveMember("blank-ended-live-debut", MemberRole.CREATOR)
|
||||||
|
|
||||||
|
saveLiveRoom(
|
||||||
|
endedLiveCreator,
|
||||||
|
now.minusDays(5),
|
||||||
|
channelName = "ended-live-channel",
|
||||||
|
isActive = false
|
||||||
|
)
|
||||||
|
saveLiveRoom(
|
||||||
|
blankChannelCreator,
|
||||||
|
now.minusDays(4),
|
||||||
|
channelName = "",
|
||||||
|
isActive = false
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val creators = repository.findRecentDebutCreators(now, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(listOf(endedLiveCreator.id), creators.map { it.creatorId })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 데뷔 크리에이터는 인기 커뮤니티 전용 부스트가 아니라 기존 신규 부스트를 유지한다")
|
@DisplayName("최근 데뷔 크리에이터는 인기 커뮤니티 전용 부스트가 아니라 기존 신규 부스트를 유지한다")
|
||||||
fun shouldKeepOriginalNewBoostForRecentDebutCreators() {
|
fun shouldKeepOriginalNewBoostForRecentDebutCreators() {
|
||||||
@@ -1593,6 +1619,35 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("랜덤 AI 캐릭터 id는 제외 id를 빼고 활성 AI 캐릭터 크리에이터 회원만 조회한다")
|
||||||
|
fun shouldFindRandomAiCharacterRecommendationIdsExcludingExistingCharacters() {
|
||||||
|
val excludedCharacter = saveCharacter("ai-random-excluded", isActive = true)
|
||||||
|
val visibleCharacter = saveCharacter("ai-random-visible", isActive = true)
|
||||||
|
val inactiveCharacter = saveCharacter("ai-random-inactive", isActive = false)
|
||||||
|
val inactiveCreatorCharacter = saveCharacter("ai-random-inactive-creator", isActive = true).apply {
|
||||||
|
creatorMember!!.isActive = false
|
||||||
|
}
|
||||||
|
val userCreatorCharacter = saveCharacter("ai-random-user-creator", isActive = true).apply {
|
||||||
|
creatorMember!!.role = MemberRole.USER
|
||||||
|
}
|
||||||
|
val humanCreatorCharacter = saveCharacter("ai-random-human-creator", isActive = true).apply {
|
||||||
|
creatorMember!!.memberKind = MemberKind.HUMAN
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val characterIds = repository.findRandomAiCharacterRecommendationIds(
|
||||||
|
excludeCharacterIds = listOf(excludedCharacter.id!!),
|
||||||
|
limit = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(visibleCharacter.id), characterIds)
|
||||||
|
assertEquals(false, characterIds.contains(inactiveCharacter.id))
|
||||||
|
assertEquals(false, characterIds.contains(inactiveCreatorCharacter.id))
|
||||||
|
assertEquals(false, characterIds.contains(userCreatorCharacter.id))
|
||||||
|
assertEquals(false, characterIds.contains(humanCreatorCharacter.id))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다")
|
@DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다")
|
||||||
fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() {
|
fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() {
|
||||||
|
|||||||
@@ -196,6 +196,53 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(null, characters.last().originalWorkTitle)
|
assertEquals(null, characters.last().originalWorkTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 AI 캐릭터 추천은 스냅샷 상세가 부족하면 랜덤 AI 캐릭터로 limit까지 채운다")
|
||||||
|
fun shouldBackfillAiCharactersWithRandomDetailsWhenSnapshotDetailsAreUnderLimit() {
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
snapshotAt,
|
||||||
|
(1L..3L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) }
|
||||||
|
)
|
||||||
|
port.aiCharacterDetailsByRequest = mapOf(
|
||||||
|
(1L..3L).toList() to listOf(aiCharacterRecord(1L, 101L)),
|
||||||
|
listOf(4L, 5L) to listOf(aiCharacterRecord(4L, 104L), aiCharacterRecord(5L, 105L))
|
||||||
|
)
|
||||||
|
port.randomAiCharacterIds = listOf(4L, 5L)
|
||||||
|
|
||||||
|
val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true)
|
||||||
|
|
||||||
|
assertEquals(listOf(4L, 5L), port.aiCharacterDetailIds)
|
||||||
|
assertEquals((1L..3L).toList(), port.randomAiCharacterExcludeIds)
|
||||||
|
assertEquals(2, port.randomAiCharacterLimit)
|
||||||
|
assertEquals(listOf((1L..3L).toList(), listOf(4L, 5L)), port.aiCharacterDetailIdRequests)
|
||||||
|
assertEquals(listOf(1L, 4L, 5L), characters.map { it.characterId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 AI 캐릭터 추천은 랜덤 후보가 부족하면 가능한 캐릭터만 중복 없이 반환한다")
|
||||||
|
fun shouldReturnAvailableAiCharactersWhenRandomBackfillIsUnderLimit() {
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
snapshotAt,
|
||||||
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L, 100.0, snapshotAt))
|
||||||
|
)
|
||||||
|
port.aiCharacterDetailsByRequest = mapOf(
|
||||||
|
listOf(1L) to listOf(aiCharacterRecord(1L, 101L)),
|
||||||
|
listOf(2L) to listOf(aiCharacterRecord(2L, 102L))
|
||||||
|
)
|
||||||
|
port.randomAiCharacterIds = listOf(2L)
|
||||||
|
|
||||||
|
val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L), port.randomAiCharacterExcludeIds)
|
||||||
|
assertEquals(2, port.randomAiCharacterLimit)
|
||||||
|
assertEquals(listOf(listOf(1L), listOf(2L)), port.aiCharacterDetailIdRequests)
|
||||||
|
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
|
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
|
||||||
fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() {
|
fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() {
|
||||||
@@ -254,10 +301,39 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
|
|
||||||
assertEquals(null, fallback.offset)
|
assertEquals(null, fallback.offset)
|
||||||
assertEquals(null, fallback.limit)
|
assertEquals(null, fallback.limit)
|
||||||
|
assertEquals(null, port.randomAiCharacterExcludeIds)
|
||||||
|
assertEquals(null, port.randomAiCharacterLimit)
|
||||||
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
|
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
|
||||||
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
|
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 전체보기는 첫 페이지여도 랜덤 보충을 적용하지 않는다")
|
||||||
|
fun shouldNotBackfillAiCharacterPageWithRandomDetails() {
|
||||||
|
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||||
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||||
|
)
|
||||||
|
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
snapshotAt,
|
||||||
|
(1L..20L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) }
|
||||||
|
)
|
||||||
|
|
||||||
|
port.aiCharacterDetails = listOf(aiCharacterRecord(1L, 101L))
|
||||||
|
port.randomAiCharacterIds = listOf(21L)
|
||||||
|
|
||||||
|
val characters = service.findAiCharacterRecommendations(offset = 0, limit = 20)
|
||||||
|
|
||||||
|
assertEquals(null, fallback.offset)
|
||||||
|
assertEquals(null, fallback.limit)
|
||||||
|
assertEquals(null, port.randomAiCharacterExcludeIds)
|
||||||
|
assertEquals(null, port.randomAiCharacterLimit)
|
||||||
|
assertEquals((1L..20L).toList(), port.aiCharacterDetailIds)
|
||||||
|
assertEquals(listOf(1L), characters.map { it.characterId })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다")
|
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다")
|
||||||
fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() {
|
fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() {
|
||||||
@@ -902,6 +978,9 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
var firstAudioMemberId: Long? = null
|
var firstAudioMemberId: Long? = null
|
||||||
var firstAudioIncludeAdultContents: Boolean? = null
|
var firstAudioIncludeAdultContents: Boolean? = null
|
||||||
var aiCharacterDetailIds: List<Long> = emptyList()
|
var aiCharacterDetailIds: List<Long> = emptyList()
|
||||||
|
val aiCharacterDetailIdRequests = mutableListOf<List<Long>>()
|
||||||
|
var randomAiCharacterExcludeIds: List<Long>? = null
|
||||||
|
var randomAiCharacterLimit: Int? = null
|
||||||
var cheerCreatorDetailIds: List<Long> = emptyList()
|
var cheerCreatorDetailIds: List<Long> = emptyList()
|
||||||
var popularCommunityDetailIds: List<Long> = emptyList()
|
var popularCommunityDetailIds: List<Long> = emptyList()
|
||||||
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
||||||
@@ -962,6 +1041,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||||
|
var aiCharacterDetailsByRequest: Map<List<Long>, List<HomeAiCharacterRecommendationRecord>> = emptyMap()
|
||||||
|
var randomAiCharacterIds: List<Long> = emptyList()
|
||||||
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||||
var cheerCreatorMemberId: Long? = null
|
var cheerCreatorMemberId: Long? = null
|
||||||
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||||
@@ -1048,9 +1129,20 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
|
|
||||||
override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> {
|
override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> {
|
||||||
aiCharacterDetailIds = characterIds
|
aiCharacterDetailIds = characterIds
|
||||||
|
aiCharacterDetailIdRequests.add(characterIds)
|
||||||
|
aiCharacterDetailsByRequest[characterIds]?.let { return it }
|
||||||
return aiCharacterDetails
|
return aiCharacterDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findRandomAiCharacterRecommendationIds(
|
||||||
|
excludeCharacterIds: List<Long>,
|
||||||
|
limit: Int
|
||||||
|
): List<Long> {
|
||||||
|
randomAiCharacterExcludeIds = excludeCharacterIds
|
||||||
|
randomAiCharacterLimit = limit
|
||||||
|
return randomAiCharacterIds.take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
override fun findCheerCreatorRecommendationDetails(
|
override fun findCheerCreatorRecommendationDetails(
|
||||||
creatorIds: List<Long>,
|
creatorIds: List<Long>,
|
||||||
memberId: Long?
|
memberId: Long?
|
||||||
@@ -1154,6 +1246,16 @@ private fun fixedClock(): Clock {
|
|||||||
return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC)
|
return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aiCharacterRecord(characterId: Long, creatorId: Long) = HomeAiCharacterRecommendationRecord(
|
||||||
|
characterId = characterId,
|
||||||
|
creatorId = creatorId,
|
||||||
|
name = "character-$characterId",
|
||||||
|
description = "description-$characterId",
|
||||||
|
profileImage = null,
|
||||||
|
totalChatCount = 0L,
|
||||||
|
originalWorkTitle = null
|
||||||
|
)
|
||||||
|
|
||||||
private class FakeCheerCreatorSnapshotFallbackPort(
|
private class FakeCheerCreatorSnapshotFallbackPort(
|
||||||
private val snapshots: List<RecommendationSnapshotRecord>
|
private val snapshots: List<RecommendationSnapshotRecord>
|
||||||
) : CheerCreatorSnapshotFallbackPort {
|
) : CheerCreatorSnapshotFallbackPort {
|
||||||
|
|||||||
Reference in New Issue
Block a user