From b1be87b46fabdec4b2e0fd52291ca9baf9a17dd4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 19 Aug 2026 12:23:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(home-following):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=EB=A5=BC=20=ED=8C=94=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 280 ++++++++++++++++++ .../prd.md | 141 +++++++++ .../room/repository/ChatRoomRepository.kt | 10 + .../application/HomeFollowingFacade.kt | 8 +- .../v2/chat/service/ChatRoomListService.kt | 7 +- .../UserCreatorChatRoomRepository.kt | 10 + .../in/web/HomeFollowingEndToEndTest.kt | 88 +++++- .../application/HomeFollowingFacadeTest.kt | 6 +- .../v2/chat/ChatRoomListServiceTest.kt | 40 ++- 9 files changed, 570 insertions(+), 20 deletions(-) create mode 100644 docs/20260819_홈_팔로잉_최근대화_팔로우필터/plan-task.md create mode 100644 docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md diff --git a/docs/20260819_홈_팔로잉_최근대화_팔로우필터/plan-task.md b/docs/20260819_홈_팔로잉_최근대화_팔로우필터/plan-task.md new file mode 100644 index 00000000..70a5bdb7 --- /dev/null +++ b/docs/20260819_홈_팔로잉_최근대화_팔로우필터/plan-task.md @@ -0,0 +1,280 @@ +# 홈 팔로잉 최근 대화 팔로우 필터 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. 체크박스는 구현 시 실제 진행 상태에 맞게 갱신한다. + +**Goal:** 홈 팔로잉 탭에서 현재 팔로우 중인 크리에이터의 DM 및 AI 채팅만 최근 대화로 제공한다. + +**Architecture:** 기존 `ChatRoomListService`의 병합·정렬·응답 변환을 재사용하고, 홈 팔로잉 호출만 `followedCreatorsOnly = true`를 전달한다. AI/DM 저장소 query는 활성 `CreatorFollowing` 존재 조건을 pagination 전에 적용하며, 기본값 `false`로 일반 채팅 목록 동작을 유지한다. + +**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA/JPQL, Gradle, JUnit 5, Mockito, MockMvc + +**Spec:** `docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md` + +## Global Constraints + +- 공개 API request/response schema를 변경하지 않는다. +- DM과 AI 채팅을 모두 포함하고 현재 `CreatorFollowing.isActive = true`인 대상만 허용한다. +- 팔로우 조건은 DB query에서 pagination보다 먼저 적용한다. +- 일반 `GET /api/v2/chat/rooms`는 기존 전체 조회 동작을 유지한다. +- DB schema, index, dependency를 추가하지 않는다. +- 메시지 본문, 인증 정보와 식별자를 새 log에 기록하지 않는다. +- 요청 범위 밖 리팩터링을 하지 않는다. + +--- + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-08-19 | +| 요구사항 기준 | `docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md` | +| API 기준 | 기존 `GET /api/v2/home/following`, `GET /api/v2/chat/rooms` 계약 유지 | +| 현재 Phase | Phase 1 완료 | +| 현재 활성 Goal | 없음 | + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `1/1` | 없음 | 없음 | + +- 동시에 하나의 미완료 goal만 운용한다. +- 사용자가 명시적으로 요청하지 않았으므로 goal token budget은 설정하지 않는다. +- 이 문서 작성 단계에서는 production/test 코드를 수정하거나 테스트를 실행하지 않는다. + +## 범위 + +### 포함 + +- 홈 팔로잉 최근 대화에 활성 팔로우 DM/AI filter 적용 +- 기존 정렬, cursor 해석, 최대 10개와 응답 변환 재사용 +- 일반 채팅 목록의 전체 조회 기본값 유지 +- facade, service, repository query와 홈 팔로잉 E2E 회귀 검증 + +### 제외 + +- 공개 API schema, 채팅방 생성과 메시지 전송 정책 변경 +- 팔로우/언팔로우 쓰기 로직과 알림 정책 변경 +- 차단·계정 활성 상태 등 추가 노출 정책 +- DB migration, index와 dependency 추가 +- 홈 팔로잉의 최근 대화 외 섹션 변경 + +## 파일 책임과 변경 범위 + +| 파일 | 계획된 책임 | +|---|---| +| `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt` | 홈 팔로잉 최근 대화 호출에 `followedCreatorsOnly = true` 전달 | +| `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt` | 팔로우 필터 옵션을 AI/DM repository에 전달하고 기존 병합·정렬 유지 | +| `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt` | AI 캐릭터 `creatorMember.id` 기준 활성 팔로우 조건 적용 | +| `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt` | DM 상대 `member.id` 기준 활성 팔로우 조건 적용 | +| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt` | facade가 홈 전용 팔로우 필터를 요청하는지 검증 | +| `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt` | filter 옵션 전달과 기존 전체 조회 회귀 검증 | +| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` | 활성 팔로우 DM/AI 포함과 미팔로우·비활성 팔로우 제외 검증 | +| `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt` | 수정 없이 일반 채팅 목록 위임 회귀 확인 | + +## Phase 1: 최근 대화 팔로우 필터 적용 + +**Phase 결과:** 홈 팔로잉 탭은 활성 팔로우 크리에이터의 DM/AI 최근 대화만 반환하고 일반 채팅 목록은 기존 동작을 유지한다. + +**선행조건:** 승인된 PRD `HFC-001~008`. + +**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, 실제 검증 결과를 Progress에 기록한다. + +### 구현 항목 + +#### Task 1.1 홈 팔로잉 최근 대화 필터 + +**Goal 실행 `P1-T1`:** 활성 팔로우 관계를 AI/DM query에 적용하고 홈 팔로잉 facade에서만 해당 필터를 활성화한다. + +- **시작 조건:** `HFC-001~008` 확정, 현재 코드와 공개 API 계약 재확인. +- **완료 증거:** 아래 체크박스 전체 완료, focused test 성공, 변경 파일과 실제 결과를 Progress에 기록. +- **범위 밖:** 채팅 목록 공개 filter enum 추가, 별도 query 복제, 메모리 후처리, 신규 abstraction·dependency·migration. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + +**Interfaces:** + +- Consumes: 요청 회원 `Member.id`, DM 상대 `opponent.member.id`, AI 대상 `pc.character.creatorMember.id`, `CreatorFollowing.isActive`. +- Produces: 기존 반환형 `ChatRoomListPageResponse`; 공개 DTO와 endpoint 계약 변경 없음. +- `ChatRoomListService.getRooms`는 아래 parameter만 마지막에 추가하고 기본값으로 기존 호출을 보존한다. + +```kotlin +fun getRooms( + member: Member, + filter: String = ChatRoomListFilter.ALL.name, + cursor: String? = null, + limit: Int = DEFAULT_LIMIT, + followedCreatorsOnly: Boolean = false +): ChatRoomListPageResponse +``` + +- `ChatRoomRepository.findAiChatListRooms`와 `UserCreatorChatRoomRepository.findDmChatListRooms`에는 `@Param("followedCreatorsOnly") followedCreatorsOnly: Boolean`을 추가한다. + +- [x] **RED:** `HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember` fixture에 활성 팔로우 DM, 활성 팔로우 AI, 더 최신인 미팔로우 DM 11개, `isActive = false` 팔로우 DM을 만든다. 응답에는 활성 팔로우 AI/DM room 두 개만 기존 최신순으로 남는 assertion을 추가해 저장소 filter가 pagination보다 먼저 적용되는지도 함께 검증한다. + +```kotlin +.andExpect(jsonPath("$.data.recentChats.length()").value(2)) +.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.aiChatRoomId)) +.andExpect(jsonPath("$.data.recentChats[0].chatType").value("AI")) +.andExpect(jsonPath("$.data.recentChats[1].roomId").value(fixture.dmChatRoomId)) +.andExpect(jsonPath("$.data.recentChats[1].chatType").value("DM")) +.andExpect(jsonPath("$.data.recentChats[?(@.roomId == ${fixture.unfollowedDmRoomIds.first()})]").isEmpty) +.andExpect(jsonPath("$.data.recentChats[?(@.roomId == ${fixture.inactiveFollowingDmRoomId})]").isEmpty) +``` + +- [x] **RED 확인:** 아래 명령을 실행해 현재 구현이 더 최신인 미팔로우·비활성 팔로우 DM을 최대 10개 반환하므로 `recentChats.length()` 또는 room 순서 assertion이 실패하는지 확인한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember" +``` + +- [x] **GREEN:** AI/DM repository query parameter를 service에서 전달하고, query의 기존 방·참가자·메시지 활성 조건 뒤에 아래 활성 팔로우 존재 조건을 추가한다. 조건은 `Pageable` 적용 전에 평가한다. + +AI query: + +```sql +AND ( + :followedCreatorsOnly = false + OR EXISTS ( + SELECT 1 FROM CreatorFollowing cf + WHERE cf.member.id = :memberId + AND cf.creator.id = pc.character.creatorMember.id + AND cf.isActive = true + ) +) +``` + +DM query: + +```sql +AND ( + :followedCreatorsOnly = false + OR EXISTS ( + SELECT 1 FROM CreatorFollowing cf + WHERE cf.member.id = :memberId + AND cf.creator.id = opponent.member.id + AND cf.isActive = true + ) +) +``` + +- [x] **GREEN:** `HomeFollowingFacade`만 아래처럼 filter를 활성화한다. 일반 `ChatRoomListController` 호출은 기본값 `false`를 사용한다. + +```kotlin +val recentChats = chatRoomListService.getRooms( + member, + filter = "ALL", + cursor = null, + limit = 10, + followedCreatorsOnly = true +).rooms +``` + +- [x] **GREEN:** `ChatRoomListServiceTest`의 repository stub/verify에 `followedCreatorsOnly` 인자를 반영하고, `true`가 AI와 DM repository 양쪽에 전달되는 test를 추가한다. 기존 test는 `false` 전달과 병합·정렬·cursor·preview가 유지되는지 검증한다. +- [x] **GREEN:** `HomeFollowingFacadeTest`가 `followedCreatorsOnly = true` 호출을 stub/verify하고 비로그인 시 무호출을 계속 검증하도록 수정한다. +- [x] **GREEN 확인:** 아래 focused test를 실행해 facade, service, query와 endpoint 동작을 함께 확인한다. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" +``` + +**Expected:** 모든 test가 통과하고 홈 팔로잉 E2E에서 활성 팔로우 AI/DM 두 방만 기존 최신순으로 반환된다. + +- [x] **REFACTOR:** 이번 Task에서 생긴 중복만 정리한다. 별도 filter enum, query method 복제와 공통 abstraction은 만들지 않는다. focused test와 아래 직접 영향 회귀·lint를 다시 실행하고 실제 결과를 Progress에 기록한다. + +### 완료 조건 + +- [x] `P1-T1`의 체크박스와 완료 증거가 모두 충족됐다. +- [x] `HFC-001~008`이 구현 또는 명시적 제외로 추적된다. +- [x] 공개 API와 DB schema 변경이 없다. +- [x] 실제 검증 결과가 Progress에 기록됐다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 홈 팔로잉 필터와 일반 채팅 목록 비회귀를 최종 판정한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 아래 test와 lint 성공, 전체 회귀 실행 여부와 근거를 Progress에 기록. +- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정. + +```bash +./gradlew test \ + --tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest" \ + --tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest" \ + --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" +./gradlew ktlintCheck +``` + +**Expected:** 모든 focused/영향 범위 test와 `ktlintCheck`가 exit code `0`으로 끝난다. 홈 팔로잉은 활성 팔로우 AI/DM만 반환하고 일반 채팅 목록은 팔로우 filter 없이 기존 전체 조회를 위임한다. + +수동 검증: + +- [x] `git diff --name-only`에 계획된 production/test 파일과 이 작업 문서 이외의 변경이 없는지 확인한다. +- [x] `git diff`에서 endpoint, response DTO, DB schema와 dependency 변경이 없는지 확인한다. +- [x] 새 log에 회원·채팅·팔로우 식별자 또는 메시지 본문이 추가되지 않았는지 확인한다. + +전체 회귀 `./gradlew test`는 작은 조회 조건 변경이며 위 test가 service, repository query, facade, endpoint와 인접 일반 채팅 목록을 포함하므로 기본 생략한다. 위 명령으로 영향 범위를 판단할 수 없는 실패가 발생하거나 공통 코드로 범위가 확장되면 전체 회귀를 실행하고 결과를 Progress에 기록한다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 승인된 PRD | 아니요 | query mapping과 fixture 근거를 PRD·현재 코드와 다시 대조 | +| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패를 소유한 Task에 회귀 수정 goal 추가 | + +```text +P1-T1 → P1-GATE +``` + +## 변경 금지 항목 + +- 기존 완료 문서와 검증 기록을 삭제하거나 덮어쓰지 않는다. +- 공개 endpoint, request/response DTO와 chat type filter enum을 변경하지 않는다. +- `followingCreators` 최대 20개 결과로 최근 대화를 메모리 후처리하지 않는다. +- AI/DM query 전체를 새 method로 복제하지 않는다. +- 신규 dependency, DB migration과 index를 추가하지 않는다. +- 관련 없는 리팩터링 또는 포맷 변경을 하지 않는다. +- test를 삭제·skip·완화하지 않는다. + +## 의사결정 및 중단 규칙 + +- PRD와 구현이 충돌하면 `prd.md`의 Decision Log를 먼저 갱신하고 이 계획을 동기화한 뒤 구현한다. +- `ChatCharacter.creatorMember.id` 또는 DM `opponent.member.id`로 활성 팔로우를 판정할 수 없으면 추정 구현을 중단하고 새 근거를 확인한다. +- 구현 범위가 공개 API, DB schema 또는 팔로우 쓰기 로직으로 확장되면 사용자 승인 전 진행하지 않는다. +- 체크박스, test와 Progress 기록이 모두 충족된 뒤에만 goal을 완료 처리한다. + +## Progress + +### 2026-08-19 `P1-T1`, `P1-GATE` 완료 + +- RED: `HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember`를 실행해 `$.data.recentChats.length()`가 기대값 `2` 대신 `10`을 반환하는 실패를 확인했다. +- GREEN: `ChatRoomListServiceTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` focused test가 exit code `0`으로 통과했다. +- Phase Gate: 위 focused test에 `ChatRoomListControllerTest`를 추가한 영향 범위 회귀와 `./gradlew ktlintCheck`가 exit code `0`으로 통과했다. +- 전체 회귀: `./gradlew test`가 exit code `0`으로 통과했다. +- 수동 검증: 변경 파일은 계획된 production/test 7개와 이 작업의 `prd.md`, `plan-task.md`뿐이며 endpoint, response DTO, DB schema, dependency와 log 변경이 없음을 `git diff`로 확인했다. +- LSP: 로컬에 `kotlin-ls`가 설치되어 있지 않아 실행하지 못했으며, Kotlin compile, 전체 test와 ktlint 성공으로 대체 검증했다. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-08-19 | `DEC-001` | 확정 | DM과 AI 채팅을 모두 활성 팔로우 대상으로 filter한다. | 사용자 인터뷰 승인 | `P1-T1`, PRD `HFC-001~003` | +| 2026-08-19 | `DEC-002` | 확정 | 기존 service/query에 기본값 `false`인 최소 옵션을 추가하고 홈 facade만 활성화한다. | 일반 채팅 목록 회귀 없이 기존 병합·정렬을 재사용하는 최소 변경 | `P1-T1` | +| 2026-08-19 | `DEC-003` | 확정 | filter는 DB query에서 pagination 전에 적용한다. | 후처리 시 최신 미팔로우 대화가 limit을 차지해 팔로우 대화가 누락될 수 있음 | `P1-T1`, PRD `HFC-004` | + +## 발견된 문제 + +현재 확정된 범위 내 발견된 문제는 없다. diff --git a/docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md b/docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md new file mode 100644 index 00000000..e5aa8acb --- /dev/null +++ b/docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md @@ -0,0 +1,141 @@ +# 홈 팔로잉 최근 대화 팔로우 필터 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 기준 확정 | +| 작성일 | 2026-08-19 | +| 최종 수정일 | 2026-08-19 | +| 대상 제품 | 메인 홈 팔로잉 탭 API의 최근 대화 섹션 | +| 작성자·결정권자 | Codex 작성, 사용자 승인 | +| 선행 PRD | `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` | +| 관련 API Contract | 별도 문서 없음. 기존 `GET /api/v2/home/following` 계약을 유지한다. | +| 관련 구현 계획 | `docs/20260819_홈_팔로잉_최근대화_팔로우필터/plan-task.md` | +| 관련 review | 없음 | + +### 선행 문서와의 관계 + +- 이 문서는 선행 PRD의 Feature D와 "최근 대화는 팔로잉 여부와 무관하다"는 Edge Case만 대체한다. +- 선행 PRD에서 정의한 나머지 팔로잉 탭 요구사항과 완료 기록은 변경하지 않는다. + +## 1. Overview + +로그인 사용자가 메인 홈 팔로잉 탭을 조회할 때 `recentChats`에는 현재 팔로우 중인 크리에이터와의 DM 및 AI 채팅만 노출한다. 기존 응답 스키마, 최신순 정렬, 최대 10개, 메시지 미리보기 정책은 유지한다. + +## 2. Problem Statement + +- `HomeFollowingFacade`는 현재 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`을 호출한다. +- 이 호출은 사용자의 모든 DM 및 AI 채팅방을 조회하므로, 팔로우하지 않은 크리에이터와의 대화도 팔로잉 탭에 노출된다. +- 팔로잉 탭의 최근 대화 섹션이 탭의 목적과 다른 대상을 보여준다. + +문제를 해결했다는 판단은 팔로잉 탭 응답에서 활성 팔로우 대상의 DM 및 AI 채팅만 최신순 최대 10개로 반환되고, 일반 채팅 목록 API의 결과는 바뀌지 않는 것으로 한다. + +## 3. Goals + +- 팔로잉 탭의 `recentChats`를 현재 활성 팔로우 관계가 있는 크리에이터의 대화로 제한한다. +- DM과 AI 채팅을 모두 포함한다. +- 팔로우 필터를 저장소 조회에 적용한 뒤 최대 10개를 선택해, 더 최신인 미팔로우 대화 때문에 결과 수가 줄지 않게 한다. +- 기존 공개 API 스키마, 정렬, 미리보기, 최대 개수 정책을 유지한다. +- 일반 채팅 목록 API의 기존 전체 조회 동작을 유지한다. + +## 4. Non-Goals + +- `GET /api/v2/home/following` 또는 `GET /api/v2/chat/rooms`의 request/response 스키마를 변경하지 않는다. +- 채팅방 생성, 메시지 전송, 읽음 처리, cursor 형식과 메시지 미리보기 정책을 변경하지 않는다. +- 팔로우/언팔로우 처리 자체와 알림 설정 정책을 변경하지 않는다. +- 차단 관계, 크리에이터 계정 활성 상태 등 이번 요청에 포함되지 않은 추가 노출 조건을 도입하지 않는다. +- DB schema, index, dependency를 추가하지 않는다. +- 선행 홈 팔로잉 PRD의 최근 대화 이외 섹션을 변경하지 않는다. + +## 5. Target Users and Permissions + +| 사용자 | 기대 결과 | +|---|---| +| 로그인 회원 | 팔로우 중인 크리에이터와의 최근 DM 및 AI 채팅을 확인한다. | +| 비로그인 사용자 | 기존과 동일하게 로그인 필요 상태와 빈 `recentChats`를 받는다. | +| 앱 클라이언트 | 변경 없는 응답 스키마로 최근 대화 섹션을 표시한다. | + +- 인증 회원의 `Member.id`를 팔로우 관계와 채팅 참가자 조회 기준으로 사용한다. +- 비로그인 요청에서는 기존과 동일하게 채팅 조회를 실행하지 않는다. + +## 6. 핵심 사용자 흐름 + +1. 사용자가 `GET /api/v2/home/following`을 호출한다. +2. 서버는 로그인 회원의 홈 팔로잉 데이터와 최근 대화를 조회한다. +3. 최근 대화 조회는 현재 `CreatorFollowing.isActive = true`인 크리에이터의 채팅만 선택한다. +4. DM과 AI 결과를 기존 정렬 규칙으로 병합하고 최대 10개를 반환한다. +5. 앱은 기존 `recentChats` 응답 필드로 채팅방 진입 화면을 구성한다. + +## 7. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 | +|---|---|---|---|---| +| `HFC-001` | 확정 | 팔로잉 탭의 최근 대화는 요청 회원이 현재 활성 팔로우 중인 크리에이터의 채팅만 포함한다. | 활성 팔로우 대상은 포함되고 팔로우 row가 없거나 `isActive = false`인 대상은 제외된다. | `P1-T1`, `P1-GATE` | +| `HFC-002` | 확정 | DM 채팅의 크리에이터 식별자는 `UserCreatorChatParticipant`의 상대 회원 `opponent.member.id`를 사용한다. | 상대 회원과 요청 회원의 활성 `CreatorFollowing`이 있을 때만 DM 방이 반환된다. | `P1-T1` | +| `HFC-003` | 확정 | AI 채팅의 크리에이터 식별자는 `ChatCharacter.creatorMember.id`를 사용한다. | AI 캐릭터의 `creatorMember`와 요청 회원의 활성 `CreatorFollowing`이 있을 때만 AI 방이 반환된다. | `P1-T1` | +| `HFC-004` | 확정 | 팔로우 필터는 저장소 조회에서 pagination보다 먼저 적용한다. | 최신 미팔로우 대화가 10개 이상이어도 그보다 오래된 팔로우 대화를 최대 10개까지 조회할 수 있다. | `P1-T1` | +| `HFC-005` | 확정 | DM과 AI 결과는 기존 `lastMessageAt`, `chatType`, `roomId` 내림차순으로 병합하고 최대 10개를 반환한다. | 기존 정렬·limit·`ChatRoomListItemResponse` 변환 테스트가 통과한다. | `P1-T1` | +| `HFC-006` | 확정 | 일반 채팅 목록은 기존처럼 팔로잉 여부와 무관하게 조회한다. | `GET /api/v2/chat/rooms`가 팔로우 필터를 요청하지 않고 기존 서비스 테스트가 통과한다. | `P1-T1`, `P1-GATE` | +| `HFC-007` | 확정 | 비로그인 홈 팔로잉 요청 동작은 유지한다. | `isLoginRequired = true`, 빈 `recentChats`, 채팅 서비스 미호출이 유지된다. | `P1-GATE` | +| `HFC-008` | 확정 | 홈 팔로잉의 크리에이터 목록 최대 20개와 관계없이 모든 활성 팔로우 관계를 최근 대화 필터에 사용한다. | `followingCreators` 응답 목록을 후처리 필터로 재사용하지 않고 DB의 활성 팔로우 관계를 직접 판정한다. | `P1-T1` | + +### Edge Cases + +- 활성 팔로우 관계가 없으면 `recentChats`는 빈 배열이다. +- 과거에 팔로우했더라도 현재 `CreatorFollowing.isActive = false`이면 해당 대화를 제외한다. +- 활성 팔로우 관계가 있는 AI와 DM 대화가 함께 있으면 두 유형 모두 기존 최신순 정렬에 포함한다. +- 방, 참가자 또는 메시지가 비활성인 경우 기존 채팅 목록 조회 조건대로 제외한다. +- 메시지가 없는 방은 기존 채팅 목록 정책대로 최근 대화에 포함하지 않는다. + +## 8. API 계약 + +### Endpoint + +- Method/Path: `GET /api/v2/home/following` +- request parameter: 변경 없음 +- 인증 처리: 변경 없음 +- response wrapper: 변경 없음 +- `recentChats`: 기존 `List` 유지 +- `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`: 필드와 의미 변경 없음 + +`GET /api/v2/chat/rooms`의 공개 계약과 전체 조회 의미도 변경하지 않는다. + +## 9. 데이터·보안·성능 요구사항 + +- 팔로우 판정은 `creator_following.member_id`, `creator_following.creator_id`, `creator_following.is_active = true`를 사용한다. +- `creator_following`의 기존 `(member_id, creator_id)` unique constraint를 활용하고 신규 index를 추가하지 않는다. +- 팔로우 여부는 응답 생성 시점의 DB 상태를 기준으로 한다. +- 회원·채팅·팔로우 식별자와 메시지 본문을 새 log로 남기지 않는다. +- 팔로우 필터는 DB query에 포함해 미팔로우 결과를 메모리에서 제거하거나 전체 대화를 로드하지 않는다. +- 신규 dependency와 DB migration을 추가하지 않는다. + +## 10. 성공 기준 + +- [x] 활성 팔로우 중인 일반 크리에이터와의 DM이 `recentChats`에 포함된다. (`HFC-001`, `HFC-002`) +- [x] 활성 팔로우 중인 AI 캐릭터와의 AI 채팅이 `recentChats`에 포함된다. (`HFC-001`, `HFC-003`) +- [x] 팔로우하지 않았거나 현재 비활성 팔로우인 크리에이터의 대화는 제외된다. (`HFC-001`) +- [x] 필터 적용 후 최신순 최대 10개와 기존 응답 필드가 유지된다. (`HFC-004`, `HFC-005`) +- [x] 일반 채팅 목록과 비로그인 홈 팔로잉 응답이 회귀하지 않는다. (`HFC-006`, `HFC-007`) +- [x] 공개 API schema, DB schema와 dependency 변경이 없다. + +## 11. Open Questions + +없음. + +인터뷰 종료 시 최종 모호성은 `0.07`이며, 명확성 점수는 Goal `1.00`, Scope `1.00`, Constraints `1.00`, Success `0.75`, Context `0.90`이다. + +## 12. 요구사항 추적표 + +| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | +|---|---:|---|---| +| `HFC-001~005`, `HFC-008` | 1 | `P1-T1` | `ChatRoomListServiceTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` | +| `HFC-006~007` | 1 | `P1-GATE` | `ChatRoomListControllerTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` | + +## 13. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal | +|---|---|---|---|---|---| +| 2026-08-19 | `DEC-001` | 확정 | 최근 대화는 DM과 AI 채팅을 모두 포함한다. | 사용자는 AI 채팅 가능 캐릭터도 크리에이터라고 확정했다. | `HFC-001~003`, `P1-T1` | +| 2026-08-19 | `DEC-002` | 확정 | 일반 채팅 목록은 유지하고 홈 팔로잉 조회에서만 활성 팔로우 필터를 사용한다. | 변경 범위를 홈 팔로잉 탭으로 제한하고 기존 공개 API 회귀를 방지한다. | `HFC-006`, `P1-T1` | +| 2026-08-19 | `DEC-003` | 확정 | 기존 완료 문서는 유지하고 이 PRD가 최근 대화 규칙만 대체한다. | 완료 이력을 보존하면서 새 변경의 범위와 검증을 독립적으로 추적한다. | 문서 전체 | diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index 70ae1858..eda85543 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -87,6 +87,15 @@ interface ChatRoomRepository : JpaRepository { AND pc.isActive = true AND r.isActive = true AND m.isActive = true + AND ( + :followedCreatorsOnly = false + OR EXISTS ( + SELECT cf.id FROM CreatorFollowing cf + WHERE cf.member.id = :memberId + AND cf.creator.id = pc.character.creatorMember.id + AND cf.isActive = true + ) + ) AND ( :cursorAt IS NULL OR m.createdAt < :cursorAt @@ -110,6 +119,7 @@ interface ChatRoomRepository : JpaRepository { @Param("cursorAt") cursorAt: LocalDateTime?, @Param("cursorChatType") cursorChatType: String?, @Param("cursorRoomId") cursorRoomId: Long?, + @Param("followedCreatorsOnly") followedCreatorsOnly: Boolean, pageable: Pageable ): List diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt index 85bd1114..49ac1735 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt @@ -17,7 +17,13 @@ class HomeFollowingFacade( } val home = homeFollowingQueryService.findHomeFollowing(member) - val recentChats = chatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10).rooms + val recentChats = chatRoomListService.getRooms( + member, + filter = "ALL", + cursor = null, + limit = 10, + followedCreatorsOnly = true + ).rooms return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt index d0e51a9c..fd047951 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt @@ -28,7 +28,8 @@ class ChatRoomListService( member: Member, filter: String = ChatRoomListFilter.ALL.name, cursor: String? = null, - limit: Int = DEFAULT_LIMIT + limit: Int = DEFAULT_LIMIT, + followedCreatorsOnly: Boolean = false ): ChatRoomListPageResponse { val type = ChatRoomListFilter.from(filter) val safeLimit = limit.coerceIn(1, DEFAULT_LIMIT) @@ -41,12 +42,14 @@ class ChatRoomListService( parsedCursor?.lastMessageAt, parsedCursor?.chatType, parsedCursor?.roomId, + followedCreatorsOnly, pageable ) + dmRoomRepository.findDmChatListRooms( member.id!!, parsedCursor?.lastMessageAt, parsedCursor?.chatType, parsedCursor?.roomId, + followedCreatorsOnly, pageable ) } @@ -56,6 +59,7 @@ class ChatRoomListService( parsedCursor?.lastMessageAt, parsedCursor?.chatType, parsedCursor?.roomId, + followedCreatorsOnly, pageable ) } @@ -65,6 +69,7 @@ class ChatRoomListService( parsedCursor?.lastMessageAt, parsedCursor?.chatType, parsedCursor?.roomId, + followedCreatorsOnly, pageable ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt index ceb1ceea..390db13a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt @@ -57,6 +57,15 @@ interface UserCreatorChatRoomRepository : JpaRepository :memberId and m.isActive = true + and ( + :followedCreatorsOnly = false + or exists ( + select cf.id from CreatorFollowing cf + where cf.member.id = :memberId + and cf.creator.id = opponent.member.id + and cf.isActive = true + ) + ) and ( :cursorAt is null or m.createdAt < :cursorAt @@ -80,6 +89,7 @@ interface UserCreatorChatRoomRepository : JpaRepository } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt index 54090af8..75099cf5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt @@ -1,5 +1,10 @@ package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.room.ChatMessage +import kr.co.vividnext.sodalive.chat.room.ChatParticipant +import kr.co.vividnext.sodalive.chat.room.ChatRoom +import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.theme.AudioContentTheme @@ -9,6 +14,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer @@ -85,10 +91,19 @@ class HomeFollowingEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.followingCreators[0].creatorNickname").value("home-following-creator")) .andExpect(jsonPath("$.data.onAirLives[0].liveId").value(fixture.liveId)) .andExpect(jsonPath("$.data.onAirLives[0].title").value("home-following-live")) - .andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.chatRoomId)) - .andExpect(jsonPath("$.data.recentChats[0].chatType").value("DM")) - .andExpect(jsonPath("$.data.recentChats[0].targetName").value("home-following-creator")) - .andExpect(jsonPath("$.data.recentChats[0].lastMessage").value("recent dm")) + .andExpect(jsonPath("$.data.recentChats.length()").value(2)) + .andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.aiChatRoomId)) + .andExpect(jsonPath("$.data.recentChats[0].chatType").value("AI")) + .andExpect(jsonPath("$.data.recentChats[1].roomId").value(fixture.dmChatRoomId)) + .andExpect(jsonPath("$.data.recentChats[1].chatType").value("DM")) + .andExpect(jsonPath("$.data.recentChats[1].targetName").value("home-following-creator")) + .andExpect(jsonPath("$.data.recentChats[1].lastMessage").value("recent dm")) + .andExpect( + jsonPath("$.data.recentChats[?(@.roomId == ${fixture.unfollowedDmRoomIds.first()})]").isEmpty + ) + .andExpect( + jsonPath("$.data.recentChats[?(@.roomId == ${fixture.inactiveFollowingDmRoomId})]").isEmpty + ) .andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}")) .andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}")) .andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString())) @@ -172,7 +187,21 @@ class HomeFollowingEndToEndTest @Autowired constructor( val rankedVisibleFromAtUtc = now.minusHours(1) saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc) val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7) - val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10)) + val dmChatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10)) + val aiCreator = saveMember( + "home-following-ai-creator", + MemberRole.CREATOR, + memberKind = MemberKind.AI_CHARACTER + ) + saveFollowing(viewer, aiCreator) + val aiChatRoom = saveAiChatRoom(viewer, aiCreator, now.minusMinutes(5)) + val unfollowedDmRoomIds = (1..11).map { index -> + val unfollowedCreator = saveMember("home-following-unfollowed-$index", MemberRole.CREATOR) + saveDmChatRoom(viewer, unfollowedCreator, now.minusSeconds(index.toLong())).id!! + } + val inactiveFollowingCreator = saveMember("home-following-inactive", MemberRole.CREATOR) + saveFollowing(viewer, inactiveFollowingCreator, isActive = false) + val inactiveFollowingDmRoom = saveDmChatRoom(viewer, inactiveFollowingCreator, now.minusMinutes(1)) entityManager.flush() entityManager.clear() @@ -181,7 +210,10 @@ class HomeFollowingEndToEndTest @Autowired constructor( creatorId = creator.id!!, liveId = live.id!!, audioId = audio.id!!, - chatRoomId = chatRoom.id!!, + dmChatRoomId = dmChatRoom.id!!, + aiChatRoomId = aiChatRoom.id!!, + unfollowedDmRoomIds = unfollowedDmRoomIds, + inactiveFollowingDmRoomId = inactiveFollowingDmRoom.id!!, rankedNewsId = rankedNews.id!!, audioNewsId = audioNews.id!!, communityNewsId = communityNews.id!!, @@ -191,21 +223,27 @@ class HomeFollowingEndToEndTest @Autowired constructor( }!! } - private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null): Member { + private fun saveMember( + seed: String, + role: MemberRole, + profileImage: String? = null, + memberKind: MemberKind = MemberKind.HUMAN + ): Member { val member = Member( email = "$seed@test.com", password = "password", nickname = seed, profileImage = profileImage, role = role, + memberKind = memberKind, countryCode = "US" ) entityManager.persist(member) return member } - private fun saveFollowing(member: Member, creator: Member): CreatorFollowing { - val following = CreatorFollowing(isActive = true).apply { + private fun saveFollowing(member: Member, creator: Member, isActive: Boolean = true): CreatorFollowing { + val following = CreatorFollowing(isActive = isActive).apply { this.member = member this.creator = creator } @@ -354,12 +392,42 @@ class HomeFollowingEndToEndTest @Autowired constructor( return room } + private fun saveAiChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): ChatRoom { + val character = ChatCharacter( + characterUUID = "home-following-ai-character-uuid", + name = "home-following-ai-character", + description = "description", + systemPrompt = "system" + ).apply { creatorMember = creator } + entityManager.persist(character) + val room = ChatRoom(sessionId = "home-following-ai-room", title = "home-following-ai-room") + entityManager.persist(room) + val viewerParticipant = ChatParticipant(room, ParticipantType.USER, member = viewer) + val characterParticipant = ChatParticipant(room, ParticipantType.CHARACTER, character = character) + entityManager.persist(viewerParticipant) + entityManager.persist(characterParticipant) + val message = ChatMessage( + message = "recent ai", + chatRoom = room, + participant = characterParticipant, + isActive = true + ) + entityManager.persist(message) + entityManager.flush() + message.createdAt = messageCreatedAt + message.updatedAt = messageCreatedAt + return room + } + private data class Fixture( val viewer: Member, val creatorId: Long, val liveId: Long, val audioId: Long, - val chatRoomId: Long, + val dmChatRoomId: Long, + val aiChatRoomId: Long, + val unfollowedDmRoomIds: List, + val inactiveFollowingDmRoomId: Long, val rankedNewsId: Long, val audioNewsId: Long, val communityNewsId: Long, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt index 753e03ef..a4f36fc1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt @@ -60,7 +60,8 @@ class HomeFollowingFacadeTest { ) Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member) Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null)) - .`when`(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10) + .`when`(chatRoomListService) + .getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) val response = facade.getFollowingTab(member) @@ -71,7 +72,8 @@ class HomeFollowingFacadeTest { assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId) assertEquals("news-5", response.recentNews.single().newsId) Mockito.verify(queryService).findHomeFollowing(member) - Mockito.verify(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10) + Mockito.verify(chatRoomListService) + .getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) } private fun homeFollowing(): HomeFollowing { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt index 1273a4f0..f2547819 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt @@ -39,7 +39,9 @@ class ChatRoomListServiceTest { val member = member(1L) val aiLastAt = LocalDateTime.of(2026, 5, 14, 11, 0) val dmLastAt = LocalDateTime.of(2026, 5, 14, 12, 0) - Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn( + Mockito.`when`( + aiRoomRepository.findAiChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31)) + ).thenReturn( listOf( ChatRoomListQueryDto( roomId = 10L, @@ -52,7 +54,9 @@ class ChatRoomListServiceTest { ) ) ) - Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn( + Mockito.`when`( + dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31)) + ).thenReturn( listOf( ChatRoomListQueryDto( roomId = 20L, @@ -80,7 +84,9 @@ class ChatRoomListServiceTest { @DisplayName("DM 필터는 DM 방만 조회하고 음성 메시지 요약 문구를 사용한다") fun shouldReturnOnlyDmRoomsWithVoicePreview() { val member = member(1L) - Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn( + Mockito.`when`( + dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31)) + ).thenReturn( listOf( ChatRoomListQueryDto( roomId = 20L, @@ -118,7 +124,9 @@ class ChatRoomListServiceTest { lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0).minusMinutes(index) ) } - Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(rows) + Mockito.`when`( + aiRoomRepository.findAiChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31)) + ).thenReturn(rows) val response = service.getRooms(member, filter = "AI", cursor = null, limit = 30) @@ -133,7 +141,9 @@ class ChatRoomListServiceTest { fun shouldKeepRoomsWithSameTimestampAfterCursor() { val member = member(1L) val cursorAt = LocalDateTime.of(2026, 5, 14, 12, 0) - Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn( + Mockito.`when`( + aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, false, PageRequest.of(0, 31)) + ).thenReturn( listOf( ChatRoomListQueryDto( roomId = 30L, @@ -146,7 +156,9 @@ class ChatRoomListServiceTest { ) ) ) - Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn( + Mockito.`when`( + dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, false, PageRequest.of(0, 31)) + ).thenReturn( listOf( ChatRoomListQueryDto( roomId = 20L, @@ -175,5 +187,21 @@ class ChatRoomListServiceTest { assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl) } + @Test + @DisplayName("팔로우 크리에이터 필터는 AI와 DM 저장소에 전달한다") + fun shouldPassFollowedCreatorsOnlyToBothRepositories() { + val member = member(1L) + val pageable = PageRequest.of(0, 11) + Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, true, pageable)) + .thenReturn(emptyList()) + Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, true, pageable)) + .thenReturn(emptyList()) + + service.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true) + + Mockito.verify(aiRoomRepository).findAiChatListRooms(1L, null, null, null, true, pageable) + Mockito.verify(dmRoomRepository).findDmChatListRooms(1L, null, null, null, true, pageable) + } + private fun member(id: Long) = Member(password = "pw", nickname = "user").apply { this.id = id } }