test #442
@@ -18,6 +18,7 @@
|
|||||||
- 메뉴는 클라이언트의 typed static configuration이 소유한다. Backend의 기존 `GET /menu` 및 메뉴 코드는 수정하지 않는다.
|
- 메뉴는 클라이언트의 typed static configuration이 소유한다. Backend의 기존 `GET /menu` 및 메뉴 코드는 수정하지 않는다.
|
||||||
- Kotlin package는 v2지만 HTTP base path는 `/admin/ai-characters`다. `/v2`, `/admin/v2`, `/v2/admin` prefix를 추가하지 않는다.
|
- Kotlin package는 v2지만 HTTP base path는 `/admin/ai-characters`다. `/v2`, `/admin/v2`, `/v2/admin` prefix를 추가하지 않는다.
|
||||||
- 신규 웹 Operation은 PRD에 명시된 66개다. 기존 `PUT /audio-content/upload-complete`는 호환 계약이므로 신규 Operation 수에 포함하지 않는다.
|
- 신규 웹 Operation은 PRD에 명시된 66개다. 기존 `PUT /audio-content/upload-complete`는 호환 계약이므로 신규 Operation 수에 포함하지 않는다.
|
||||||
|
- 기존 `PUT /audio-content/upload-complete`는 인증 정보 없음·유효하지 않은 JWT에 `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할에 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 유지한다.
|
||||||
- `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`은 `/admin/ai-characters/original-works`의 global 원작 CRUD·검색·캐릭터 배정 계약이다. legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response와 일반 사용자용 `/api/chat/original/**`는 호환을 위해 유지한다.
|
- `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`은 `/admin/ai-characters/original-works`의 global 원작 CRUD·검색·캐릭터 배정 계약이다. legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response와 일반 사용자용 `/api/chat/original/**`는 호환을 위해 유지한다.
|
||||||
- `CONTENT-08`이라는 신규 Endpoint, V1/V2 dispatcher와 v2 completion use case를 만들지 않는다. v2 콘텐츠도 기존 row·S3 계약을 따라 현재 callback이 동일하게 처리한다.
|
- `CONTENT-08`이라는 신규 Endpoint, V1/V2 dispatcher와 v2 completion use case를 만들지 않는다. v2 콘텐츠도 기존 row·S3 계약을 따라 현재 callback이 동일하게 처리한다.
|
||||||
- AWS S3 Trigger worker 코드, worker 스케줄, metadata 계약 및 AWS Trigger 설정은 생성·수정하지 않는다.
|
- AWS S3 Trigger worker 코드, worker 스케줄, metadata 계약 및 AWS Trigger 설정은 생성·수정하지 않는다.
|
||||||
@@ -212,24 +213,25 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
|
|
||||||
### Phase 0: 기존 callback·소비자 계약 고정
|
### Phase 0: 기존 callback·소비자 계약 고정
|
||||||
|
|
||||||
- [ ] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정**
|
- [x] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정**
|
||||||
- Files:
|
- Files:
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt`
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt`
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt`
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt`
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
|
||||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
||||||
- RED: TDD 예외 사유: 변경 대상이 아닌 기존 callback·소비자 계약을 characterization test로 고정하는 작업이므로 의도적인 production 결함을 먼저 만들지 않는다.
|
- RED: TDD 예외 사유: 변경 대상이 아닌 기존 callback·소비자 계약을 characterization test로 고정하는 작업이므로 의도적인 production 결함을 먼저 만들지 않는다.
|
||||||
- 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 기존 `PUT /audio-content/upload-complete`, legacy 캐릭터·원작 관리자, 일반 사용자용 원작 API, BOT/ADMIN 인가, Request/Response 및 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다.
|
- 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 callback의 Method·Path·Request·`ADMIN`/`BOT` 성공 Response·인가 status, legacy 캐릭터·원작 관리자의 Method·Path·Request·성공 Response, 일반 사용자용 원작 API와 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. callback은 인증 정보 없음·유효하지 않은 JWT `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할 `403`을 고정한다. legacy 검색은 실제 repository/service로 검색 field, 활성·삭제 제외, 정렬과 pagination을 고정한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다.
|
||||||
- GREEN: 이 Task에서는 신규 route를 구현하지 않는다. callback과 기존 소비자 API가 현재 상태에서 통과하는지 baseline을 기록한다.
|
- GREEN: 이 Task에서는 신규 route를 구현하지 않는다. callback과 기존 소비자 API가 현재 상태에서 통과하는지 baseline을 기록한다.
|
||||||
- REFACTOR: 테스트 fixture는 실제 Spring mapping과 응답 surface를 검증하며 운영 코드를 위한 범용 endpoint registry를 만들지 않는다.
|
- REFACTOR: 테스트 fixture는 실제 Spring mapping과 응답 surface를 검증하며 운영 코드를 위한 범용 endpoint registry를 만들지 않는다.
|
||||||
- 기대 결과: 기존 callback, legacy 캐릭터·원작 관리자와 소비자 API 계약이 초록색 baseline으로 고정된다.
|
- 기대 결과: 기존 callback, legacy 캐릭터·원작 관리자와 소비자 API 계약이 초록색 baseline으로 고정된다.
|
||||||
|
|
||||||
### Phase 1: 공통 관리자 계약, 보안, 대상 해석
|
### Phase 1: 공통 관리자 계약, 보안, 대상 해석
|
||||||
|
|
||||||
- [ ] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현**
|
- [x] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현**
|
||||||
- Files:
|
- Files:
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt`
|
||||||
@@ -238,30 +240,34 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt`
|
||||||
- RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 이미지 bytes의 실제 MIME과 `allowGif` 조건을 테스트한다.
|
- RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 빈 multipart JSON과 단일 root 뒤의 추가 root·garbage 거부, 이미지 bytes의 실제 MIME `image/jpeg`, `image/png`, `image/gif`, 10MB·한 변 20,000px·총 40,000,000 pixels 초과 거부를 테스트한다. PNG는 ancillary payload 합계 1MB·chunk 4,096개 상한과 `ignoreMetadata=true`를 검증한다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels 상한을 검증하고, logical canvas·모든 frame header의 동일 상한, 선언 pixel 수와 정확히 일치하는 LZW 출력, 조기 EOI·연속 clear·EOI 뒤 data와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력 거부를 고정한다. 실제 format 확인 시 모든 GIF frame을 각각 1x1 출력 영역으로 decode하는지와 `allowGif` 조건도 테스트한다.
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`
|
||||||
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
||||||
- GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다.
|
- GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, multipart JSON string은 parser 전용 strict reader로 단일 root만 허용한다. `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. PNG ImageIO 입력은 `ignoreMetadata=true`로 metadata를 읽지 않고, container preflight에서 ancillary payload 합계 1MB와 chunk 4,096개를 제한한다. GIF preflight는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels를 제한하고 각 frame LZW 출력이 선언 pixel 수와 정확히 일치하는지 bounded code parser로 확인한다. 실제 format 확인 decode는 모든 frame의 1x1 출력 영역으로 제한하고, 입력 유래 decoder 예외를 잘못된 이미지로 처리한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다.
|
||||||
- REFACTOR: 기존 `CreatorChannel*QueryPolicy`는 size 1~19 처리 계약이 다르므로 수정하거나 재사용하지 않는다.
|
- REFACTOR: 기존 `CreatorChannel*QueryPolicy`는 size 1~19 처리 계약이 다르므로 수정하거나 재사용하지 않는다.
|
||||||
- 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용한다.
|
- 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용하고, image part는 bytes·container 구조·metadata·frame·LZW 작업량의 bounded 안전 경계를 공유한다.
|
||||||
|
|
||||||
- [ ] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현**
|
- [x] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현**
|
||||||
- Files:
|
- Files:
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt`
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt`
|
||||||
- RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 같은 예외가 legacy route에서는 기존 HTTP 200 관례를 유지하는 회귀 케이스도 추가한다.
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt`
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest`
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt`
|
||||||
|
- RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 실제 Spring Boot context에서 두 production advice와 `SecurityConfig`를 함께 로드하고, DispatcherServlet에 연결된 test `MultipartResolver`가 handler 선택 전에 실패할 때 admin 경로는 400, legacy 경로는 기존 HTTP 200과 unknown 메시지를 유지하는지도 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest`
|
||||||
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
||||||
- GREEN: `SodaException` 생성자 끝에 legacy 기본 동작을 보존하는 선택 HTTP status를 추가하고, 우선순위가 높은 admin Controller 범위 advice와 path-specific security handler만 그 status를 응답에 사용한다.
|
- GREEN: `SodaException` 생성자 끝에 legacy 기본 동작을 보존하는 선택 HTTP status를 추가하고, 우선순위가 높은 admin Controller 범위 advice와 path-specific security handler만 그 status를 응답에 사용한다.
|
||||||
- REFACTOR: 기존 `SodaExceptionHandler`, `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`의 응답을 변경하지 않는다.
|
- REFACTOR: 기존 `SodaExceptionHandler`, `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`의 응답을 변경하지 않는다.
|
||||||
- 기대 결과: 신규 관리자 API만 PRD 20장의 status/envelope를 사용하고 legacy API는 영향받지 않는다.
|
- 기대 결과: 신규 관리자 API만 PRD 20장의 status/envelope를 사용하고 legacy API는 영향받지 않는다.
|
||||||
|
|
||||||
- [ ] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현**
|
- [x] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현**
|
||||||
- Files:
|
- Files:
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt`
|
||||||
@@ -276,17 +282,17 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
- REFACTOR: JPA `ChatCharacter`, `Member`, Q type은 persistence adapter 밖으로 노출하지 않는다.
|
- REFACTOR: JPA `ChatCharacter`, `Member`, Q type은 persistence adapter 밖으로 노출하지 않는다.
|
||||||
- 기대 결과: 모든 character-scoped facade가 동일한 대상 해석 결과를 사용한다.
|
- 기대 결과: 모든 character-scoped facade가 동일한 대상 해석 결과를 사용한다.
|
||||||
|
|
||||||
- [ ] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현**
|
- [x] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현**
|
||||||
- Files:
|
- Files:
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt`
|
||||||
- RED: direct callback의 commit 후 1회 실행, rollback/동일 command 재시도에서 미실행·비중복, transaction 안에서 publish한 기존 FCM/언어 event listener가 commit 후 실행되고 유실되지 않는지, mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 CRUD는 nullable character field를 허용하고 원작 배정·해제는 캐릭터별 context를 요구하는지도 고정한다.
|
- RED: direct callback의 commit 후 1회 실행과 rollback 미실행을 검증하고, 실제 `TransactionTemplate`에서 첫 attempt가 rollback된 동일 command를 재시도해 commit하면 callback이 두 attempt 합계 1회 실행되는 기존 동작을 characterization으로 고정한다. `AfterCommitExecutor`에는 command identity가 없으므로 attempt 간 dedup은 요구하지 않는다. 기존 `FcmEvent`는 commit 후, `LanguageDetectEvent`는 commit 후·rollback 시, `LanguageTranslationEvent(waitTransactionCommit=true)`는 commit 후 listener 동작을 각각 검증하고 세 listener의 `AFTER_COMMIT` 선언도 고정한다. mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 context는 `ORIGINAL_WORK`의 `CREATE/UPDATE/DELETE`만, 원작 배정·해제는 캐릭터별 `ORIGINAL_WORK_CHARACTER` context만 허용한다. character-scoped context의 `creatorMemberId`는 `UNASSIGN`에서만 nullable로 두되, factory가 정상/과거 불일치 해제 타입을 구분하지 않는다. factory 검증을 우회하는 public `copy`도 노출하지 않는다.
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`
|
||||||
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
||||||
- GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 global/character-scoped context를 명시적으로 구분하는 정도로만 추가하며 audit table이나 AOP framework는 만들지 않는다.
|
- GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 private factory로 global/character-scoped context와 action/resource/creator 조합을 강제하되 정상/과거 불일치 해제용 별도 command나 factory를 만들지 않으며, audit table이나 AOP framework도 만들지 않는다.
|
||||||
- REFACTOR: 비밀번호, JWT, system prompt 전체, 댓글/게시글 본문, 업로드 파일 내용이 logger argument에 들어갈 수 없도록 audit context를 ID와 enum 중심으로 제한한다.
|
- REFACTOR: 비밀번호, JWT, system prompt 전체, 댓글/게시글 본문, 업로드 파일 내용이 logger argument에 들어갈 수 없도록 audit context를 ID와 enum 중심으로 제한한다.
|
||||||
- 기대 결과: 이후 facade와 event adapter가 같은 commit/audit 원칙을 반복 구현하지 않는다.
|
- 기대 결과: 이후 facade와 event adapter가 같은 commit/audit 원칙을 반복 구현하지 않는다.
|
||||||
|
|
||||||
@@ -402,7 +408,7 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt`
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt`
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt`
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt`
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt`
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt`
|
||||||
- RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다.
|
- RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. 해제 audit은 캐릭터의 `creatorMemberId`를 확인할 수 있으면 실제 값을 기록하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 기록한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다.
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminOriginalWorkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.original.LegacyOriginalWorkMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest`
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminOriginalWorkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.original.LegacyOriginalWorkMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest`
|
||||||
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
||||||
- GREEN: 신규 Controller는 `AdminJsonRequestParser`, `AdminImagePartValidator`와 실제 `adminMemberId`만 사용하고 facade는 원작 use case 호출, Response 변환과 audit만 담당한다. `ORIGINAL-WORK-08`은 `Content-Type: application/json` DELETE body를 명시적으로 매핑한다. legacy 원작 compatibility adapter는 legacy DTO와 multipart의 non-null update field만 v2 호환 patch command로 변환한다. 현재 값 병합은 adapter 선조회가 아니라 v2 application이 원작 row를 잠근 transaction 안에서 수행한다. 원작 조회는 기존 service를 유지하고 legacy 원작 5개 direct mutation만 v2 input으로 수렴한다. legacy 캐릭터가 아직 호출하는 `assignOneCharacter`는 Phase 10의 전체 캐릭터 호환 전환 전까지만 남기고, 나머지 미사용 direct mutation 메서드와 그로 인해 불필요해진 dependency만 제거해 OSIV 회귀 fixture의 constructor를 맞춘다.
|
- GREEN: 신규 Controller는 `AdminJsonRequestParser`, `AdminImagePartValidator`와 실제 `adminMemberId`만 사용하고 facade는 원작 use case 호출, Response 변환과 audit만 담당한다. `ORIGINAL-WORK-08`은 `Content-Type: application/json` DELETE body를 명시적으로 매핑한다. legacy 원작 compatibility adapter는 legacy DTO와 multipart의 non-null update field만 v2 호환 patch command로 변환한다. 현재 값 병합은 adapter 선조회가 아니라 v2 application이 원작 row를 잠근 transaction 안에서 수행한다. 원작 조회는 기존 service를 유지하고 legacy 원작 5개 direct mutation만 v2 input으로 수렴한다. legacy 캐릭터가 아직 호출하는 `assignOneCharacter`는 Phase 10의 전체 캐릭터 호환 전환 전까지만 남기고, 나머지 미사용 direct mutation 메서드와 그로 인해 불필요해진 dependency만 제거해 OSIV 회귀 fixture의 constructor를 맞춘다.
|
||||||
@@ -433,10 +439,10 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt`
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt`
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt`
|
||||||
- RED: 기존 callback의 method/path/BOT·ADMIN/Request/`data={}`는 그대로인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다.
|
- RED: 기존 callback의 method/path/Request와 `ADMIN`/`BOT` 성공 `data={}`는 그대로이고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다.
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest`
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest`
|
||||||
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
||||||
- GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive`를 `false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller, Request/Response, scheduler component의 cron·lock은 변경하지 않는다.
|
- GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive`를 `false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller의 Method·Path·Request와 `ADMIN`/`BOT` 성공 Response, scheduler component의 cron·lock은 변경하지 않는다.
|
||||||
- REFACTOR: 신규 callback Controller/DTO/use case, V1/V2 dispatcher, pipeline metadata와 v2 scheduler가 생기지 않았는지 diff를 확인한다.
|
- REFACTOR: 신규 callback Controller/DTO/use case, V1/V2 dispatcher, pipeline metadata와 v2 scheduler가 생기지 않았는지 diff를 확인한다.
|
||||||
- 기대 결과: 기존 callback과 scheduler를 모든 콘텐츠가 공용하면서 삭제 콘텐츠와 비활성 creator를 다시 공개하지 않는다.
|
- 기대 결과: 기존 callback과 scheduler를 모든 콘텐츠가 공용하면서 삭제 콘텐츠와 비활성 creator를 다시 공개하지 않는다.
|
||||||
|
|
||||||
@@ -860,7 +866,7 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminInactiveStateEndToEndTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminInactiveStateEndToEndTest.kt`
|
||||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminAuditEndToEndTest.kt`
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminAuditEndToEndTest.kt`
|
||||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt`
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt`
|
||||||
- RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다.
|
- RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 정상/과거 불일치 해제를 별도 command나 factory로 나누지 않되, 해제 audit은 확인 가능한 실제 `creatorMemberId`를 기록하고 연결 정보가 없거나 해석할 수 없을 때만 `null`을 기록한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다.
|
||||||
- 실패 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdmin*EndToEndTest' --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest`
|
- 실패 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdmin*EndToEndTest' --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest`
|
||||||
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
- 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다.
|
||||||
- GREEN: 누락된 owner/status/audit 연결만 각 facade/use case에 보완한다.
|
- GREEN: 누락된 owner/status/audit 연결만 각 facade/use case에 보완한다.
|
||||||
@@ -929,7 +935,7 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
13. `awk '$0=="### 27.8 Copy-Paste Frontend Development Prompt"{section=1; next} section && $0=="```text"{capture=1} capture{print} capture && $0=="```"{exit}' docs/20260720_AI캐릭터_관리자기능/prd.md | shasum -a 256` 결과가 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`인지 확인한다.
|
13. `awk '$0=="### 27.8 Copy-Paste Frontend Development Prompt"{section=1; next} section && $0=="```text"{capture=1} capture{print} capture && $0=="```"{exit}' docs/20260720_AI캐릭터_관리자기능/prd.md | shasum -a 256` 결과가 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`인지 확인한다.
|
||||||
14. `find docs/20260720_AI캐릭터_관리자기능 -type f -name '*.sql' -print` 출력이 0건인지 확인한다.
|
14. `find docs/20260720_AI캐릭터_관리자기능 -type f -name '*.sql' -print` 출력이 0건인지 확인한다.
|
||||||
15. `rg -o '^### Phase [0-9]+' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 12인지 확인한다.
|
15. `rg -o '^### Phase [0-9]+' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 12인지 확인한다.
|
||||||
16. `rg -o '^- \[ \] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다.
|
16. `rg -o '^- \[[ x]\] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다.
|
||||||
17. `rg -c '^ - 실패 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md`와 `rg -c '^ - 통과 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 각각 35인지 확인한다.
|
17. `rg -c '^ - 실패 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md`와 `rg -c '^ - 통과 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 각각 35인지 확인한다.
|
||||||
18. `rg -c '^ - RED: TDD 예외' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 2인지 확인한다.
|
18. `rg -c '^ - RED: TDD 예외' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 2인지 확인한다.
|
||||||
19. `rg -n $'\t| +$' docs/20260720_AI캐릭터_관리자기능/prd.md docs/20260720_AI캐릭터_관리자기능/plan-task.md docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 출력이 0건인지 확인하고, 각 문서의 code fence 개수가 짝수인지 확인한다. untracked 문서는 `git diff --check`만으로 검사되지 않으므로 파일 자체 검사도 수행한다.
|
19. `rg -n $'\t| +$' docs/20260720_AI캐릭터_관리자기능/prd.md docs/20260720_AI캐릭터_관리자기능/plan-task.md docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 출력이 0건인지 확인하고, 각 문서의 code fence 개수가 짝수인지 확인한다. untracked 문서는 `git diff --check`만으로 검사되지 않으므로 파일 자체 검사도 수행한다.
|
||||||
@@ -959,12 +965,36 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
|
|
||||||
## 5. 구현 시 검증 기록
|
## 5. 구현 시 검증 기록
|
||||||
|
|
||||||
이 문서를 생성한 현재는 구현 전이므로 Task checkbox를 모두 미체크로 유지한다. 구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다.
|
구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다. Phase 0/1은 완료했고 Phase 2 이후 구현 Task는 미착수 상태로 유지한다.
|
||||||
|
|
||||||
- 문서 생성 검증: 아래 “문서 자체 검증 기록”에만 기록한다.
|
- 문서 생성 검증: 아래 “문서 자체 검증 기록”에만 기록한다.
|
||||||
- 코드 구현 검증: 아직 실행하지 않음.
|
- 코드 구현 검증: Phase 0/1 구현 중 아래 명령을 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`가 미구현 클래스 참조로 실패했다.
|
||||||
|
- 리뷰 4차 RED 확인: parser trailing root·garbage 2건, 이미지 전체 decode·dimension·pixel 3건, audit invalid 조합 3건과 public `copy` 1건이 각각 기존 구현에서 예상한 이유로 실패했다. Phase 0 legacy fixed-wire 보강과 rollback→retry는 기존 동작을 고정하는 characterization test라 production 변경 없이 통과했다.
|
||||||
|
- 리뷰 5차 RED 확인: malformed GIF의 `IndexOutOfBoundsException`·입력 유래 `IllegalArgumentException`, 20,001px logical canvas, 40MP 초과 후속 frame 4건과 character-scoped `ORIGINAL_WORK`, `UNASSIGN` 외 `creatorMemberId=null` audit 2건이 기존 구현에서 예상한 이유로 실패했다. 빈 multipart JSON, 실제 legacy 검색 query, multipart downstream 전달 보강은 기존 동작을 고정하는 characterization test로 추가했다.
|
||||||
|
- Phase 0 baseline: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 통과.
|
||||||
|
- Phase 1 GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 이 명령의 `AdminImagePartValidatorTest`는 10MB 초과 거부와 JPEG/PNG/GIF 실제 MIME·GIF 허용 조건을 고정한다.
|
||||||
|
- Phase 0+1 통합 targeted: 위 Phase 0/1 대상 테스트 전체를 한 Gradle 명령으로 실행해 통과.
|
||||||
|
- 리뷰 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest` 통과. 이 보강은 legacy 캐릭터·원작 관리자 mutation 성공 `ApiResponse` surface, `/admin/ai-characters` segment 경계, exception handler status 행렬, target resolver 404/409 행렬, 기존 event listener AFTER_COMMIT 경계, audit `PIN` action을 포함한다.
|
||||||
|
- 리뷰 2차 보강 targeted: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest' --tests 'kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest' --tests 'kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest'` 통과. 이 보강은 `/admin/member/login`이 생성한 token의 기존 `TokenProvider` 검증·인증 복원, legacy 캐릭터·원작 mutation의 `data` 부재 계약, 일반 event publish의 commit/rollback transactional listener 경계를 포함한다.
|
||||||
|
- 리뷰 3차 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest` 통과. 이 보강은 실제 FCM event listener의 commit, 언어 감지 listener의 commit/rollback, `waitTransactionCommit=true` 언어 번역 listener의 commit과 세 listener의 `AFTER_COMMIT` 선언, upload-complete production `SecurityConfig`의 ADMIN/BOT JWT 허용·USER 403·익명 401, 일반 원작·legacy 원작·legacy 캐릭터 응답 field, CONTENT_MANAGER 실제 로그인 JWT의 신규 관리자 API 403을 포함한다.
|
||||||
|
- Task 1.4 retry characterization targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. 실제 `TransactionTemplate`에서 첫 attempt rollback 후 동일 command를 재시도해 commit하면 direct callback은 두 attempt 합계 1회 실행됐고, 새 테스트가 production `AfterCommitExecutor` 변경 없이 통과해 현 구현의 회귀 계약으로 기록했다.
|
||||||
|
- 리뷰 4차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. production 원작 공개 matcher, legacy 캐릭터·원작 검색과 캐릭터 multipart 전체 wire, trailing JSON 거부, 이미지 decode·dimension·pixel 경계, audit action/resource/creator 조합과 public `copy` 부재, rollback 후 direct callback 재시도를 함께 검증했다.
|
||||||
|
- 리뷰 5차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. GIF logical canvas·모든 frame header와 malformed decoder 경계, audit global/character creator 불변식, 실제 JPA legacy 검색 field·상태·정렬·pagination, 캐릭터 외부 API exact method/path/JSON 및 원작 update DTO 전달을 함께 검증했다.
|
||||||
|
- 리뷰 6차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 첫 frame은 정상이지만 LZW 데이터가 손상된 두 번째 frame을 기존 구현이 decode하지 않아 예외가 발생하지 않았고, 16건 중 신규 테스트 1건이 `AssertionFailedError`로 실패했다.
|
||||||
|
- 리뷰 6차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 33건의 실패·오류가 0건이며, GIF 모든 frame의 1x1 decode, callback의 `ADMIN`/`BOT` 성공·`USER` 403·유효하지 않은 JWT와 익명 401, 해제 audit factory의 nullable 경계를 함께 검증했다.
|
||||||
|
- Full regression: `./gradlew --no-daemon test` 통과. 리뷰 6차 GIF 구현 후 재실행도 `BUILD SUCCESSFUL in 4m 26s`로 통과했다.
|
||||||
|
- Lint: `./gradlew --no-daemon ktlintCheck` 통과. 리뷰 6차 최종 Kotlin 변경 후 재실행도 `BUILD SUCCESSFUL in 26s`로 통과했다.
|
||||||
|
- 리뷰 7차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 PNG ancillary chunk와 GIF extension payload가 ImageIO custom provider까지 도달해 신규 preflight 테스트 2건이 실패했다. `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`는 legacy malformed multipart 메시지 계약 변경을 신규 테스트로 고정했다. Phase 1 누락 테스트는 hard-coded ID 전달, missing character, `creatorMember=null`, JsonNode missing-key 경계를 보강했다.
|
||||||
|
- 리뷰 7차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest`가 각각 통과했다. 이어서 위 5개 테스트 class를 한 Gradle 명령으로 묶어 재실행해 `BUILD SUCCESSFUL in 1m 2s`를 확인했다.
|
||||||
|
- 리뷰 7차 최종 검증: reviewer gate의 PNG length overflow와 정상 metadata 오탐 지적을 반영한 뒤 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`가 `BUILD SUCCESSFUL in 30s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 23s`, `./gradlew --no-daemon test`가 `BUILD SUCCESSFUL in 7m 14s`로 통과했다. LSP는 기존과 동일하게 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 실행하지 못했다.
|
||||||
|
- 리뷰 8차 RED 확인: PNG metadata reader flag, GIF extension 개수·sub-block 개수·누적 frame pixels·초과 LZW 출력 5건이 기존 구현에서 ImageIO 전에 거부되지 않아 `AdminImagePartValidatorTest`의 신규 assertion으로 실패했다.
|
||||||
|
- 리뷰 8차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`가 `BUILD SUCCESSFUL in 4m 51s`로 통과했다. 정확한 PNG ancillary/chunk, GIF frame/extension/sub-block/cumulative pixel 및 LZW 조기 종료·연속 clear·EOI 뒤 data 경계와 실제 Spring Boot DispatcherServlet multipart pre-handler admin 400/legacy 200 계약을 포함한다.
|
||||||
|
- 리뷰 8차 lint: `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 1m 3s`로 통과했다.
|
||||||
|
- 리뷰 8차 full regression: `./gradlew --no-daemon test`가 `BUILD SUCCESSFUL in 14m 7s`로 통과했다.
|
||||||
|
- LSP: 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 `lsp_diagnostics`는 실행하지 못했다.
|
||||||
- DB schema migration: 없음.
|
- DB schema migration: 없음.
|
||||||
- 구현 기준 commit/기존 dirty·untracked manifest: 구현 시작 시 기록.
|
- 구현 기준 commit/기존 dirty·untracked manifest: 시작 기준 commit `5b700892`, 기존 dirty/untracked 없음.
|
||||||
- staging worker/Trigger E2E: 아직 실행하지 않음.
|
- staging worker/Trigger E2E: 아직 실행하지 않음.
|
||||||
- production 배포: 이 계획의 코드 작성 단계만으로 완료 처리하지 않음.
|
- production 배포: 이 계획의 코드 작성 단계만으로 완료 처리하지 않음.
|
||||||
|
|
||||||
@@ -986,4 +1016,6 @@ Phase 0 기존 callback·소비자 계약 고정
|
|||||||
- 원작 보강 후 계약 검증: PRD의 JSON code block 51개와 Frontend delta의 label JSON 값 20개(Operation Request/Response 12개, 공통 오류 3개, nullable 값 5개)가 모두 유효하다. PRD와 Frontend delta의 원작 `(Operation ID, Method, Path)`는 8/8, Operation Request/Response JSON의 key·value·배열·null 구조는 12/12 exact equality다. 원작 DTO nullable·배정 응답 의미, legacy mutation 5개와 캐릭터 mutation 2개의 v2 수렴, 잠금·보상·오류 경계는 PRD와 plan에서 일치한다.
|
- 원작 보강 후 계약 검증: PRD의 JSON code block 51개와 Frontend delta의 label JSON 값 20개(Operation Request/Response 12개, 공통 오류 3개, nullable 값 5개)가 모두 유효하다. PRD와 Frontend delta의 원작 `(Operation ID, Method, Path)`는 8/8, Operation Request/Response JSON의 key·value·배열·null 구조는 12/12 exact equality다. 원작 DTO nullable·배정 응답 의미, legacy mutation 5개와 캐릭터 mutation 2개의 v2 수렴, 잠금·보상·오류 경계는 PRD와 plan에서 일치한다.
|
||||||
- 원작 보강 후 Markdown/DB 검증: 세 문서의 code fence 수가 각각 146개, 2개, 2개로 짝이 맞고 trailing whitespace·tab과 SQL 파일은 0건이다.
|
- 원작 보강 후 Markdown/DB 검증: 세 문서의 code fence 수가 각각 146개, 2개, 2개로 짝이 맞고 trailing whitespace·tab과 SQL 파일은 0건이다.
|
||||||
- 원작 보강 후 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이다.
|
- 원작 보강 후 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이다.
|
||||||
|
- 리뷰 6차 문서·Gradle 구성 검증: `./gradlew --no-daemon tasks --all`은 `BUILD SUCCESSFUL in 7s`, `git diff --check`와 세 문서의 trailing whitespace·tab 검사는 출력 0건이다. code fence 수는 각각 146개, 2개, 2개로 짝이 맞고 callback 전체 Response를 호환 대상으로 표현하는 잔여 문구도 0건이다.
|
||||||
|
- 리뷰 8차 문서 검증: Task 1.1/1.2 checkbox를 완료로 갱신했고 `git diff --check`, 세 문서의 trailing whitespace·tab 및 code fence 짝 검사를 실행해 이상 없음을 확인했다. `./gradlew tasks --all`은 기존 검증 결과를 유지하며, 이번 재실행은 Gradle wrapper lock 파일 권한 오류로 실행하지 못했다.
|
||||||
- 미실행 외부 Gate: 구현 전 문서 검증 단계이므로 실제 운영 데이터의 삭제 원작 연결 건수, MySQL 8 동시성, staging worker/Trigger E2E와 production 배포는 실행하지 않았다.
|
- 미실행 외부 Gate: 구현 전 문서 검증 단계이므로 실제 운영 데이터의 삭제 원작 연결 건수, MySQL 8 동시성, staging worker/Trigger E2E와 production 배포는 실행하지 않았다.
|
||||||
|
|||||||
@@ -1506,7 +1506,8 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 위 Method, Path, 인증, Request와 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다.
|
- 위 Method, Path, Request와 `ADMIN`/`BOT`의 성공 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다.
|
||||||
|
- 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 이 인가 status는 유지해야 하는 기존 보안 계약이다.
|
||||||
- 이 Endpoint는 기존 API이므로 신규 Operation ID를 부여하거나 12.1의 신규 Endpoint 수에 포함하지 않는다.
|
- 이 Endpoint는 기존 API이므로 신규 Operation ID를 부여하거나 12.1의 신규 Endpoint 수에 포함하지 않는다.
|
||||||
- v2 콘텐츠 생성도 기존과 같은 `content` 테이블 필드, S3 bucket의 `input/{contentId}/{contentId}-content-...` 경로와 object metadata 계약을 사용한다. object basename은 기존 `generateFileName(prefix = "${contentId}-content")` 규칙을 따르며 metadata는 기존 `generate_preview`, 선택 `preview_start_time`, `preview_end_time`만 전달한다.
|
- v2 콘텐츠 생성도 기존과 같은 `content` 테이블 필드, S3 bucket의 `input/{contentId}/{contentId}-content-...` 경로와 object metadata 계약을 사용한다. object basename은 기존 `generateFileName(prefix = "${contentId}-content")` 규칙을 따르며 metadata는 기존 `generate_preview`, 선택 `preview_start_time`, `preview_end_time`만 전달한다.
|
||||||
- v2 생성 여부를 저장하는 DB 컬럼이나 worker metadata를 추가하지 않는다. 기존 callback Controller·Request·Response·Service 호출 흐름에도 V1/V2 dispatcher를 추가하지 않는다.
|
- v2 생성 여부를 저장하는 DB 컬럼이나 worker metadata를 추가하지 않는다. 기존 callback Controller·Request·Response·Service 호출 흐름에도 V1/V2 dispatcher를 추가하지 않는다.
|
||||||
@@ -1970,7 +1971,7 @@ Content-Type: multipart/form-data
|
|||||||
- `content`는 trim 후 빈 값일 수 없고 `price`는 0 이상이다.
|
- `content`는 trim 후 빈 값일 수 없고 `price`는 0 이상이다.
|
||||||
- `price > 0`인 유료 게시글은 `postImage`가 필수다.
|
- `price > 0`인 유료 게시글은 `postImage`가 필수다.
|
||||||
- `audioFile`을 보내는 게시글은 가격과 관계없이 `postImage`가 필수다.
|
- `audioFile`을 보내는 게시글은 가격과 관계없이 `postImage`가 필수다.
|
||||||
- 업로드 이미지의 실제 MIME type은 `image/*`여야 하며 GIF는 유료 게시글에서만 허용한다.
|
- 업로드 이미지의 실제 MIME type은 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며 GIF는 유료 게시글에서만 허용한다.
|
||||||
- `audioFile`은 빈 파일일 수 없다. v2 community web adapter가 파일명 확장자가 아니라 실제 bytes를 검사해 M4A/AAC 계열 MIME type인 `audio/mp4`, `audio/x-m4a`, `audio/aac`만 허용하고, 다른 codec이나 MIME type은 400으로 거부한다.
|
- `audioFile`은 빈 파일일 수 없다. v2 community web adapter가 파일명 확장자가 아니라 실제 bytes를 검사해 M4A/AAC 계열 MIME type인 `audio/mp4`, `audio/x-m4a`, `audio/aac`만 허용하고, 다른 codec이나 MIME type은 400으로 거부한다.
|
||||||
|
|
||||||
#### Response Data
|
#### Response Data
|
||||||
@@ -2004,7 +2005,7 @@ Content-Type: multipart/form-data
|
|||||||
`postId`와 `isActive`는 body에서 받지 않는다.
|
`postId`와 `isActive`는 body에서 받지 않는다.
|
||||||
|
|
||||||
`price`와 `audioFile`은 등록 후 변경하거나 제거할 수 없다. 선택 `postImage`를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다.
|
`price`와 `audioFile`은 등록 후 변경하거나 제거할 수 없다. 선택 `postImage`를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다.
|
||||||
수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/*`여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다.
|
수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다.
|
||||||
|
|
||||||
#### Response Data
|
#### Response Data
|
||||||
|
|
||||||
@@ -2658,7 +2659,7 @@ ADMIN JWT
|
|||||||
|
|
||||||
이미 삭제된 원작의 반복 DELETE는 과거 불일치 연결이 남아 있어도 성공 200이므로 위 “연결 캐릭터가 남은 원작 삭제” 409보다 먼저 판정한다. 원작 이미지 보상 삭제가 실패해도 이미 발생한 로컬 transaction 실패의 500을 다른 성공이나 502로 바꾸지 않고 orphan 운영 로그를 남긴다.
|
이미 삭제된 원작의 반복 DELETE는 과거 불일치 연결이 남아 있어도 성공 200이므로 위 “연결 캐릭터가 남은 원작 삭제” 409보다 먼저 판정한다. 원작 이미지 보상 삭제가 실패해도 이미 발생한 로컬 transaction 실패의 500을 다른 성공이나 502로 바꾸지 않고 orphan 운영 로그를 남긴다.
|
||||||
|
|
||||||
기존 `/audio-content/upload-complete`의 인증·오류 응답 계약은 이번 범위에서 변경하지 않는다. 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증은 기존 callback의 Request·Response 또는 오류 envelope를 변경하지 않는다.
|
기존 `/audio-content/upload-complete`는 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 이번 범위에서 변경하지 않으며, 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증도 callback의 Request·성공 Response를 변경하지 않는다.
|
||||||
|
|
||||||
## 21. Technical Requirements
|
## 21. Technical Requirements
|
||||||
|
|
||||||
@@ -2792,7 +2793,7 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작
|
|||||||
- 외부 작업이나 보상 결과와 관계없이 DB 변경까지 완료되지 않은 요청은 성공으로 응답하지 않는다.
|
- 외부 작업이나 보상 결과와 관계없이 DB 변경까지 완료되지 않은 요청은 성공으로 응답하지 않는다.
|
||||||
- 파일 업로드 실패 시 부분 DB 리소스를 성공으로 반환하지 않는다.
|
- 파일 업로드 실패 시 부분 DB 리소스를 성공으로 반환하지 않는다.
|
||||||
- v2 업로드 요청은 기존 worker가 이미 처리하는 S3 bucket, `input/{contentId}/{contentId}-content-...` key와 metadata 계약을 그대로 사용하며 callback URL, pipeline version 또는 새 분기 정보를 worker에 전달하지 않는다.
|
- v2 업로드 요청은 기존 worker가 이미 처리하는 S3 bucket, `input/{contentId}/{contentId}-content-...` key와 metadata 계약을 그대로 사용하며 callback URL, pipeline version 또는 새 분기 정보를 worker에 전달하지 않는다.
|
||||||
- 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 바뀌지 않는지 contract test로 검증한다.
|
- 기존 `/audio-content/upload-complete`의 Method, Path, Request와 `ADMIN`/`BOT` 성공 Response가 바뀌지 않고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`인지 contract test로 검증한다.
|
||||||
- v2에서 생성한 기존 형식의 content row도 현재 `AudioContentService.uploadComplete`와 예약 공개 흐름이 처리하는지 통합 검증한다.
|
- v2에서 생성한 기존 형식의 content row도 현재 `AudioContentService.uploadComplete`와 예약 공개 흐름이 처리하는지 통합 검증한다.
|
||||||
- callback은 `releaseDate=null` 또는 연결 creator 비활성인 콘텐츠를 공개하지 않고, 기존 예약 공개 query는 활성 creator만 선택하도록 최소 안전 조건을 보강한다. `AudioContentReleaseScheduledTask`의 cron·lock과 worker 코드는 수정하지 않는다.
|
- callback은 `releaseDate=null` 또는 연결 creator 비활성인 콘텐츠를 공개하지 않고, 기존 예약 공개 query는 활성 creator만 선택하도록 최소 안전 조건을 보강한다. `AudioContentReleaseScheduledTask`의 cron·lock과 worker 코드는 수정하지 않는다.
|
||||||
- 캐릭터 삭제 시 raw `content.isActive=false`가 되므로 일반 사용자용 목록·검색·추천은 기존 공개 조건으로 이를 제외한다. 직접 상세 조회는 비활성 creator 또는 비활성 콘텐츠를 미구매 사용자에게 반환하지 않되, 기존 주문을 확인한 `KEEP`·`RENTAL` 구매자의 재생 경로는 유지한다.
|
- 캐릭터 삭제 시 raw `content.isActive=false`가 되므로 일반 사용자용 목록·검색·추천은 기존 공개 조건으로 이를 제외한다. 직접 상세 조회는 비활성 creator 또는 비활성 콘텐츠를 미구매 사용자에게 반환하지 않되, 기존 주문을 확인한 `KEEP`·`RENTAL` 구매자의 재생 경로는 유지한다.
|
||||||
@@ -2812,23 +2813,24 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `adminMemberId` | 실제 인증된 사람 관리자 |
|
| `adminMemberId` | 실제 인증된 사람 관리자 |
|
||||||
| `characterId` | 선택 AI 캐릭터. global 원작 CRUD에서는 `null`, 배정·해제에서는 캐릭터별 로그에 값 기록 |
|
| `characterId` | 선택 AI 캐릭터. global 원작 CRUD에서는 `null`, 배정·해제에서는 캐릭터별 로그에 값 기록 |
|
||||||
| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, legacy 불일치 캐릭터 해제에서는 `null` 가능 |
|
| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, 해제에서는 해당 Member ID를 확인할 수 있으면 값이고 과거 불일치로 연결 정보가 없거나 해석할 수 없을 때만 `null` 가능 |
|
||||||
| `action` | CREATE, UPDATE, DELETE, ASSIGN, UNASSIGN, PIN 등 |
|
| `action` | CREATE, UPDATE, DELETE, ASSIGN, UNASSIGN, PIN 등 |
|
||||||
| `resourceType` | CHARACTER, ORIGINAL_WORK, ORIGINAL_WORK_CHARACTER, CONTENT, CONTENT_COMMENT, CONTENT_CATEGORY, SERIES, COMMUNITY_POST, COMMUNITY_COMMENT, FAN_TALK, FAN_TALK_REPLY, CHANNEL_NOTICE, CHANNEL_PROFILE |
|
| `resourceType` | CHARACTER, ORIGINAL_WORK, ORIGINAL_WORK_CHARACTER, CONTENT, CONTENT_COMMENT, CONTENT_CATEGORY, SERIES, COMMUNITY_POST, COMMUNITY_COMMENT, FAN_TALK, FAN_TALK_REPLY, CHANNEL_NOTICE, CHANNEL_PROFILE |
|
||||||
| `resourceId` | 변경 대상 ID |
|
| `resourceId` | 변경 대상 ID |
|
||||||
| `result` | SUCCESS 또는 FAILURE |
|
| `result` | SUCCESS 또는 FAILURE |
|
||||||
|
|
||||||
원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다.
|
원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다. 정상 해제와 과거 불일치 해제를 별도 command나 audit factory로 구분하지 않는다. 해제 호출자는 캐릭터에서 `creatorMemberId`를 확인할 수 있으면 반드시 전달하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 전달한다.
|
||||||
|
|
||||||
영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다.
|
영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다.
|
||||||
|
|
||||||
### 21.7 Security
|
### 21.7 Security
|
||||||
|
|
||||||
- `/admin/ai-characters/**` Controller는 class level에서 `hasRole('ADMIN')`을 선언한다.
|
- `/admin/ai-characters/**` Controller는 class level에서 `hasRole('ADMIN')`을 선언한다.
|
||||||
- 기존 `/audio-content/upload-complete`의 `hasAnyRole('BOT', 'ADMIN')`과 외부 응답 계약은 변경하지 않는다.
|
- 기존 `/audio-content/upload-complete`의 `hasAnyRole('BOT', 'ADMIN')`을 유지한다. 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`이며, `ADMIN`/`BOT`의 기존 성공 응답 계약은 변경하지 않는다.
|
||||||
- `/admin/ai-characters/**` RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 `ApiResponse` 계약을 보장한다.
|
- `/admin/ai-characters/**` RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 `ApiResponse` 계약을 보장한다.
|
||||||
- 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다.
|
- 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다.
|
||||||
- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 실제 MIME type을 검증한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다.
|
- Multipart JSON string part는 단일 JSON root만 허용하고 뒤에 이어진 추가 root나 garbage를 400으로 거부한다.
|
||||||
|
- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 bytes 적재 전에 10MB 초과를 거부하고, 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나인지 검증한다. 실제 format 확인 decode는 출력 영역을 1x1로 제한하되 내부 decoder row/loop 때문에 한 변은 최대 20,000px, 총 픽셀은 최대 40,000,000 pixels로 제한한다. PNG는 ancillary payload 합계 1MB와 chunk 4,096개를 상한으로 두고 ImageIO 입력을 `ignoreMetadata=true`로 설정해 metadata를 읽지 않는다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 모든 frame의 누적 40,000,000 pixels를 상한으로 둔다. GIF logical canvas와 모든 frame header에도 같은 dimension 상한을 적용하고 각 frame의 LZW 출력 pixel 수가 선언된 width와 height의 곱과 정확히 일치해야 한다. 조기 EOI·연속 clear·EOI 뒤 data가 있는 LZW와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력은 거부한다. 첫 frame뿐 아니라 모든 frame을 각각 1x1 출력 영역으로 decode해 malformed header/frame을 400으로 거부한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다.
|
||||||
- 콘텐츠 원본 오디오는 빈 파일을 거부하고 worker에 전달한다. 지원 codec과 재생 가능 여부는 worker가 검증하며 실패한 콘텐츠를 `PUBLISHED`로 전환하지 않는다.
|
- 콘텐츠 원본 오디오는 빈 파일을 거부하고 worker에 전달한다. 지원 codec과 재생 가능 여부는 worker가 검증하며 실패한 콘텐츠를 `PUBLISHED`로 전환하지 않는다.
|
||||||
- 관리자 응답에서 system prompt는 캐릭터 상세에만 포함하며 목록에는 포함하지 않는다.
|
- 관리자 응답에서 system prompt는 캐릭터 상세에만 포함하며 목록에는 포함하지 않는다.
|
||||||
- `CONTENT-01`, `CONTENT-02`는 Signed URL이 포함될 수 있으므로 `Cache-Control: private, no-store`를 반환한다.
|
- `CONTENT-01`, `CONTENT-02`는 Signed URL이 포함될 수 있으므로 `Cache-Control: private, no-store`를 반환한다.
|
||||||
@@ -2911,7 +2913,7 @@ legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작
|
|||||||
- [ ] 실제 관리자와 대행 AI 캐릭터를 구분하는 구조화 로그가 남는다.
|
- [ ] 실제 관리자와 대행 AI 캐릭터를 구분하는 구조화 로그가 남는다.
|
||||||
- [ ] v2 비즈니스 로직과 persistence adapter가 legacy Controller, Service, Repository 또는 web DTO를 호출하지 않는다.
|
- [ ] v2 비즈니스 로직과 persistence adapter가 legacy Controller, Service, Repository 또는 web DTO를 호출하지 않는다.
|
||||||
- [ ] 신규 upload-complete Endpoint를 만들지 않고 기존 AWS S3 Trigger worker의 코드·스케줄·설정을 변경하지 않는다.
|
- [ ] 신규 upload-complete Endpoint를 만들지 않고 기존 AWS S3 Trigger worker의 코드·스케줄·설정을 변경하지 않는다.
|
||||||
- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, 인증과 Request/Response가 그대로 유지된다.
|
- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, Request와 `ADMIN`/`BOT` 성공 Response가 유지되고 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다.
|
||||||
- [ ] v2 콘텐츠 생성이 기존 `content` row와 S3 key·metadata 계약을 따르고 별도 V1/V2 callback 분기 없이 기존 callback과 예약 공개 흐름으로 처리된다.
|
- [ ] v2 콘텐츠 생성이 기존 `content` row와 S3 key·metadata 계약을 따르고 별도 V1/V2 callback 분기 없이 기존 callback과 예약 공개 흐름으로 처리된다.
|
||||||
- [ ] CONTENT-03 원본 object가 `input/{contentId}/{contentId}-content-...` key를 사용하고 worker 결과 basename이 기존 callback의 content ID 검증을 통과한다.
|
- [ ] CONTENT-03 원본 object가 `input/{contentId}/{contentId}-content-...` key를 사용하고 worker 결과 basename이 기존 callback의 content ID 검증을 통과한다.
|
||||||
- [ ] staging에서 v2 S3 input 저장부터 기존 callback 완료까지 E2E가 통과하고 DB commit 전 callback 도착 시 worker retry 동작이 확인된다.
|
- [ ] staging에서 v2 S3 input 저장부터 기존 callback 완료까지 E2E가 통과하고 DB commit 전 callback 도착 시 worker retry 동작이 확인된다.
|
||||||
|
|||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user