diff --git a/Projects/App/AppTests/Sources/AppDelegateFeatureDeeplinkTests.swift b/Projects/App/AppTests/Sources/AppDelegateFeatureDeeplinkTests.swift new file mode 100644 index 00000000..27233c2d --- /dev/null +++ b/Projects/App/AppTests/Sources/AppDelegateFeatureDeeplinkTests.swift @@ -0,0 +1,137 @@ +import ComposableArchitecture +import CoreKit +import Foundation +import Testing +import UserNotifications + +@testable import App + +@MainActor +struct AppDelegateFeatureDeeplinkTests { + @Test("푸시 payload deeplink가 있으면 라우터에 해당 URL을 전달") + func deeplinkPayloadRoutesToRouter() async throws { + let recorder = URLRecorder() + var completionCalled = false + + let response = makeResponse( + userInfo: ["deepLink": "pokit://shared?categoryId=10&contentId=2&userId=3"] + ) + + let store = TestStore(initialState: AppDelegateFeature.State()) { + AppDelegateFeature() + } withDependencies: { + $0[DeeplinkRouteClient.self].routeTo = { url in + await recorder.append(url) + } + } + + let task = await store.send(.userNotifications(.didReceiveResponse( + response, + completionHandler: { completionCalled = true } + ))) + await task.finish() + + let urls = await recorder.values() + try assertSingleURL( + urls, + expected: "pokit://shared?categoryId=10&contentId=2&userId=3" + ) + try assertCompletionCalled(completionCalled) + } + + @Test("푸시 payload deepLink가 누락되면 pokit://alert fallback 전달") + func missingPayloadFallsBackToAlertRoute() async throws { + let recorder = URLRecorder() + var completionCalled = false + + let response = makeResponse(userInfo: [:]) + + let store = TestStore(initialState: AppDelegateFeature.State()) { + AppDelegateFeature() + } withDependencies: { + $0[DeeplinkRouteClient.self].routeTo = { url in + await recorder.append(url) + } + } + + let task = await store.send(.userNotifications(.didReceiveResponse( + response, + completionHandler: { completionCalled = true } + ))) + await task.finish() + + let urls = await recorder.values() + try assertSingleURL(urls, expected: "pokit://alert") + try assertCompletionCalled(completionCalled) + } + + @Test("푸시 payload deepLink 문자열이 잘못되어도 pokit://alert fallback 전달") + func invalidPayloadFallsBackToAlertRoute() async throws { + let recorder = URLRecorder() + var completionCalled = false + + let response = makeResponse(userInfo: ["deepLink": "not a url"]) + + let store = TestStore(initialState: AppDelegateFeature.State()) { + AppDelegateFeature() + } withDependencies: { + $0[DeeplinkRouteClient.self].routeTo = { url in + await recorder.append(url) + } + } + + let task = await store.send(.userNotifications(.didReceiveResponse( + response, + completionHandler: { completionCalled = true } + ))) + await task.finish() + + let urls = await recorder.values() + try assertSingleURL(urls, expected: "pokit://alert") + try assertCompletionCalled(completionCalled) + } +} + +private actor URLRecorder { + private var urls: [URL] = [] + + func append(_ url: URL?) { + guard let url else { return } + urls.append(url) + } + + func values() -> [URL] { + urls + } +} + +private func makeResponse(userInfo: [AnyHashable: Any]) -> UserNotificationClient.Notification.Response { + let content = UNMutableNotificationContent() + content.userInfo = userInfo + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + return .init( + notification: .init(date: Date(), request: request) + ) +} + +private func assertSingleURL(_ urls: [URL], expected: String) throws { + guard urls.count == 1 else { + throw TestAssertionError("URL 개수가 1이 아닙니다. actual: \(urls.map(\.absoluteString))") + } + guard urls.first?.absoluteString == expected else { + throw TestAssertionError("URL이 다릅니다. expected: \(expected), actual: \(urls.first?.absoluteString ?? "nil")") + } +} + +private func assertCompletionCalled(_ completionCalled: Bool) throws { + guard completionCalled else { + throw TestAssertionError("completionHandler가 호출되지 않았습니다.") + } +} + +private struct TestAssertionError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { + self.description = description + } +} diff --git a/Projects/App/AppTests/Sources/AppTests.swift b/Projects/App/AppTests/Sources/AppTests.swift deleted file mode 100644 index edcec259..00000000 --- a/Projects/App/AppTests/Sources/AppTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import ComposableArchitecture -import XCTest - -@testable import App - -final class AppTests: XCTestCase { - func test() { - - } -} diff --git a/Projects/App/AppTests/Sources/MainTabFeatureDeeplinkTests.swift b/Projects/App/AppTests/Sources/MainTabFeatureDeeplinkTests.swift new file mode 100644 index 00000000..b1d0d03c --- /dev/null +++ b/Projects/App/AppTests/Sources/MainTabFeatureDeeplinkTests.swift @@ -0,0 +1,380 @@ +import Foundation + +import ComposableArchitecture +import CoreKit +import Domain +import FeatureCategorySetting +import FeatureContentDetail +import Testing + +@testable import App + +@MainActor +struct MainTabFeatureDeeplinkTests { + @Test("카카오 openURL 공유딥링크는 해당 카테고리 상세로 이동") + func kakaoOpenURLRoutesToSharedCategory() async throws { + let routeSpy = KakaoRouteSpy() + var initialState = MainTabFeature.State() + initialState.selectedTab = .recommend + let store = makeStore( + initialState: initialState, + deeplinkRouteClient: routeSpy.client + ) + + await store.send(.view(.onAppear)) + + let url = try #require( + URL(string: "kakao7890f93caf1d9d5da976da4b4bc6e5e7://kakaolink?categoryId=2&shareType=share") + ) + await store.send(.view(.onOpenURL(url: url))) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.공유받은_카테고리_조회) + await store.receive(\.inner.공유받은_카테고리_이동) + + let routedURLs = await routeSpy.routedURLs() + #expect(routedURLs == [url]) + #expect(topCategoryID(in: store.state) == 2) + #expect(store.state.path.count == 1) + + await store.skipInFlightEffects() + } + + @Test("포킷 alert 딥링크는 알림함으로 이동") + func alertRouteMovesToAlertBox() async throws { + let router = DeeplinkRouteClient.liveValue + let store = makeStore(deeplinkRouteClient: router) + + await store.send(.view(.onAppear)) + await router.routeTo(URL(string: "pokit://alert")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.delegate.알림함이동) + + #expect(isAlertPathTop(in: store.state)) + #expect(store.state.path.count == 1) + + await store.skipInFlightEffects() + } + + @Test("포킷 shared(contentId)는 카테고리 진입 후 contentDetail을 연다") + func sharedRouteWithContentOpensContentDetail() async throws { + let router = DeeplinkRouteClient.liveValue + var initialState = MainTabFeature.State() + initialState.selectedTab = .recommend + let store = makeStore( + initialState: initialState, + deeplinkRouteClient: router + ) + + await store.send(.view(.onAppear)) + await router.routeTo(URL(string: "pokit://shared?categoryId=2&contentId=777")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + + #expect(topCategoryID(in: store.state) == 2) + #expect(store.state.contentDetail == .init(contentId: 777)) + #expect(store.state.path.count == 1) + + await store.skipInFlightEffects() + } + + @Test("포킷 shared(userId)는 카테고리 진입 후 참여인원 시트를 연다") + func sharedRouteWithUserOpensParticipantsSheet() async throws { + let router = DeeplinkRouteClient.liveValue + var initialState = MainTabFeature.State() + initialState.selectedTab = .recommend + let store = makeStore( + initialState: initialState, + deeplinkRouteClient: router + ) + + await store.send(.view(.onAppear)) + await router.routeTo(URL(string: "pokit://shared?categoryId=2&userId=999")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + + #expect(topCategoryID(in: store.state) == 2) + let categoryPathID = try #require(store.state.path.ids.last) + + await store.receive(\.path[id: categoryPathID].카테고리상세.view.참여인원_버튼_눌렀을때) + await store.receive(\.path[id: categoryPathID].카테고리상세.inner.참여인원_시트_활성화) + + await store.skipInFlightEffects() + } + + @Test("링크 상세에서 내 포킷 저장용 포킷 추가 delegate를 받으면 포킷 추가 화면을 push한다") + func contentDetailAddPokitDelegatePushesCategoryCreate() async throws { + let router = DeeplinkRouteClient.liveValue + let store = makeStore(deeplinkRouteClient: router) + + await store.send(.view(.onAppear)) + await router.routeTo(URL(string: "pokit://shared?categoryId=2&contentId=777")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + + #expect(topCategoryID(in: store.state) == 2) + #expect(store.state.contentDetail == .init(contentId: 777)) + + await store.send(.contentDetail(.presented(.delegate(.포킷_추가하기_버튼_눌렀을때)))) + + #expect(store.state.contentDetail == .init(contentId: 777)) + #expect(store.state.path.count == 2) + + await store.skipInFlightEffects() + } + + @Test("앱 실행 직후 queued shared 딥링크도 정상 소비된다") + func queuedSharedRouteBeforeOnAppearIsConsumed() async throws { + let queueRouter = QueueableRouteSpy() + await queueRouter.enqueue(.pokitShared(categoryId: 2, contentId: 777, userId: nil)) + + let store = makeStore(deeplinkRouteClient: queueRouter.client) + await store.send(.view(.onAppear)) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + + #expect(topCategoryID(in: store.state) == 2) + #expect(store.state.contentDetail == .init(contentId: 777)) + + await store.skipInFlightEffects() + } + + @Test("같은 카테고리로 재라우팅하면 스택이 중복 push되지 않는다") + func rerouteToSameCategoryDoesNotDuplicatePush() async throws { + let router = DeeplinkRouteClient.liveValue + let store = makeStore(deeplinkRouteClient: router) + + await store.send(.view(.onAppear)) + await router.routeTo(URL(string: "pokit://shared?categoryId=2&contentId=777")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + + let stackCountBeforeReroute = store.state.path.count + let topPathID = try #require(store.state.path.ids.last) + + #expect(topCategoryID(in: store.state) == 2) + #expect(store.state.contentDetail == .init(contentId: 777)) + + await router.routeTo(URL(string: "pokit://shared?categoryId=2&contentId=778")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + await store.receive(\.path[id: topPathID].카테고리상세.inner.타입_변경) + await store.receive(\.path[id: topPathID].카테고리상세.inner.pagenation_초기화) + await store.receive(\.path[id: topPathID].카테고리상세.async.카테고리_내_컨텐츠_목록_조회_API) + await store.receive(\.path[id: topPathID].카테고리상세.async.포킷_초대된_유저_목록_조회_API) + + #expect(store.state.path.count == stackCountBeforeReroute) + #expect(topCategoryID(in: store.state) == 2) + #expect(store.state.contentDetail == .init(contentId: 778)) + + await store.skipInFlightEffects() + } + + @Test("다른 카테고리로 재라우팅하면 스택에 추가 이동된다") + func rerouteToDifferentCategoryAppendsStack() async throws { + let router = DeeplinkRouteClient.liveValue + let store = makeStore(deeplinkRouteClient: router) + + await store.send(.view(.onAppear)) + await router.routeTo(URL(string: "pokit://shared?categoryId=2&contentId=777")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + + #expect(topCategoryID(in: store.state) == 2) + #expect(store.state.contentDetail == .init(contentId: 777)) + + await router.routeTo(URL(string: "pokit://shared?categoryId=3&contentId=888")) + + await store.receive(\.inner.딥링크_수신) + await store.receive(\.async.포킷_딥링크_처리) + await store.receive(\.inner.포킷_딥링크_이동) + + #expect(categoryIDs(in: store.state) == [2, 3]) + #expect(store.state.contentDetail == .init(contentId: 888)) + + await store.skipInFlightEffects() + } +} + +private extension MainTabFeatureDeeplinkTests { + func makeStore( + initialState: MainTabFeature.State = .init(), + deeplinkRouteClient: DeeplinkRouteClient = .liveValue + ) -> TestStore { + let store = TestStore(initialState: initialState) { + MainTabFeature() + } withDependencies: { + $0.applyMainTabDeeplinkTestDependencies( + deeplinkRouteClient: deeplinkRouteClient + ) + } + store.exhaustivity = .off + return store + } + + func topCategoryID(in state: MainTabFeature.State) -> Int? { + guard case let .카테고리상세(categoryState) = state.path.last else { + return nil + } + return categoryState.category.id + } + + func categoryIDs(in state: MainTabFeature.State) -> [Int] { + state.path.compactMap { path in + guard case let .카테고리상세(categoryState) = path else { return nil } + return categoryState.category.id + } + } + + func isAlertPathTop(in state: MainTabFeature.State) -> Bool { + guard let topPath = state.path.last else { return false } + if case .알림함 = topPath { + return true + } + return false + } +} + +private func makeCategory(id: Int, name: String) -> BaseCategoryItem { + .init( + id: id, + userId: 100, + categoryName: name, + categoryImage: .init(imageId: 2000 + id, imageURL: "https://example.com/category-\(id).png"), + contentCount: 0, + createdAt: "", + openType: .공개, + keywordType: .default, + userCount: 2, + isFavorite: false + ) +} + +private func makeSharedCategory(id: Int, name: String) -> BaseCategoryItem { + .init( + id: id, + userId: 0, + categoryName: name, + categoryImage: .init(imageId: 2000 + id, imageURL: "https://example.com/category-\(id).png"), + contentCount: 1, + createdAt: "", + openType: .공개, + keywordType: .default, + userCount: 0, + isFavorite: false + ) +} + +private struct TestAssertionError: Error, CustomStringConvertible { + let description: String + + init(_ description: String) { + self.description = description + } +} + +private actor KakaoRouteSpy { + private var routedURLList: [URL] = [] + private var continuation: AsyncStream.Continuation? + + nonisolated var client: DeeplinkRouteClient { + DeeplinkRouteClient( + routeTo: { url in + await self.routeTo(url) + }, + routeStream: { + self.makeStream() + } + ) + } + + func routedURLs() -> [URL] { + routedURLList + } + + private func routeTo(_ url: URL?) { + guard let url else { return } + routedURLList.append(url) + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let categoryId = components? + .queryItems? + .first(where: { $0.name == "categoryId" })? + .value + .flatMap(Int.init) ?? 0 + let shareType = components? + .queryItems? + .first(where: { $0.name == "shareType" })? + .value + + continuation?.yield(.kakaoSharedCategory( + categoryId: categoryId, + shareType: shareType + )) + } + + nonisolated private func makeStream() -> AsyncStream { + AsyncStream { continuation in + Task { + await self.setContinuation(continuation) + } + } + } + + private func setContinuation( + _ continuation: AsyncStream.Continuation + ) { + self.continuation = continuation + } +} + +private actor QueueableRouteSpy { + private var queuedRoutes: [DeeplinkRoute] = [] + private var continuation: AsyncStream.Continuation? + + nonisolated var client: DeeplinkRouteClient { + DeeplinkRouteClient( + routeTo: { _ in }, + routeStream: { + self.makeStream() + } + ) + } + + func enqueue(_ route: DeeplinkRoute) { + queuedRoutes.append(route) + } + + nonisolated private func makeStream() -> AsyncStream { + AsyncStream { continuation in + Task { + await self.setContinuationAndDrain(continuation) + } + } + } + + private func setContinuationAndDrain( + _ continuation: AsyncStream.Continuation + ) { + self.continuation = continuation + for route in queuedRoutes { + continuation.yield(route) + } + queuedRoutes.removeAll() + } +} diff --git a/Projects/App/AppTests/Sources/RootFeatureTests.swift b/Projects/App/AppTests/Sources/RootFeatureTests.swift new file mode 100644 index 00000000..ddbe958d --- /dev/null +++ b/Projects/App/AppTests/Sources/RootFeatureTests.swift @@ -0,0 +1,130 @@ +import ComposableArchitecture +import CoreKit +import Foundation +import Testing + +@testable import App + +@MainActor +struct RootFeatureTests { + @Test("fcmToken이 없으면 userClient 호출 없이 즉시 mainTab으로 이동") + func moveToTabWithoutFCMTokenRoutesImmediately() async throws { + let tokenRecorder = TokenRecorder() + let writeRecorder = StringWriteRecorder() + + let store = TestStore(initialState: RootFeature.State()) { + RootFeature() + } withDependencies: { + $0[UserDefaultsClient.self].stringKey = { key in + switch key { + case .fcmToken: + return nil + default: + return nil + } + } + $0[UserDefaultsClient.self].setString = { value, key in + await writeRecorder.append(value: value, key: key) + } + $0[UserClient.self].fcm_토큰_저장 = { request in + await tokenRecorder.append(request.token) + return makeFCMResponse(userId: 1, token: "unused-token") + } + } + + await store.send(.intro(.delegate(.moveToTab))) + await store.receive(\._sceneChange) { + $0 = .mainTab() + } + + let requestedTokens = await tokenRecorder.values() + #expect(requestedTokens.isEmpty) + + let writes = await writeRecorder.values() + #expect(writes.isEmpty) + } + + @Test("fcmToken이 있으면 저장 API 성공 후 mainTab으로 이동하고 토큰/유저ID를 저장") + func moveToTabWithFCMTokenStoresUserInfo() async throws { + let tokenRecorder = TokenRecorder() + let writeRecorder = StringWriteRecorder() + + let store = TestStore(initialState: RootFeature.State()) { + RootFeature() + } withDependencies: { + $0[UserDefaultsClient.self].stringKey = { key in + switch key { + case .fcmToken: + return "device-fcm-token" + default: + return nil + } + } + $0[UserDefaultsClient.self].setString = { value, key in + await writeRecorder.append(value: value, key: key) + } + $0[UserClient.self].fcm_토큰_저장 = { request in + await tokenRecorder.append(request.token) + return makeFCMResponse(userId: 42, token: "server-fcm-token") + } + } + + await store.send(.intro(.delegate(.moveToTab))) + await store.receive(\._sceneChange) { + $0 = .mainTab() + } + + let requestedTokens = await tokenRecorder.values() + #expect(requestedTokens == ["device-fcm-token"]) + + let writes = await writeRecorder.values() + #expect( + writes == [ + .init(value: "server-fcm-token", key: .fcmToken), + .init(value: "42", key: .userId) + ] + ) + } +} + +private struct StringWrite: Equatable { + let value: String + let key: UserDefaultsKey.StringKey +} + +private actor StringWriteRecorder { + private var writes: [StringWrite] = [] + + func append(value: String, key: UserDefaultsKey.StringKey) { + writes.append(.init(value: value, key: key)) + } + + func values() -> [StringWrite] { + writes + } +} + +private actor TokenRecorder { + private var tokens: [String] = [] + + func append(_ token: String) { + tokens.append(token) + } + + func values() -> [String] { + tokens + } +} + +private func makeFCMResponse(userId: Int, token: String) -> FCMResponse { + let payload = """ + { + "userId": \(userId), + "token": "\(token)" + } + """ + + let data = payload.data(using: .utf8)! + + return try! JSONDecoder().decode(FCMResponse.self, from: data) +} diff --git a/Projects/App/AppTests/Sources/Support/MainTabDeeplinkTestDependencies.swift b/Projects/App/AppTests/Sources/Support/MainTabDeeplinkTestDependencies.swift new file mode 100644 index 00000000..6e9572d5 --- /dev/null +++ b/Projects/App/AppTests/Sources/Support/MainTabDeeplinkTestDependencies.swift @@ -0,0 +1,566 @@ +// +// MainTabDeeplinkTestDependencies.swift +// App +// +// Created by 김도형 on 2/18/26. +// + +import Foundation + +import CoreKit +import Dependencies + +extension DependencyValues { + mutating func applyMainTabDeeplinkTestDependencies( + deeplinkRouteClient: DeeplinkRouteClient = .liveValue + ) { + self[CategoryClient.self] = .mainTabDeeplinkTestValue + self[ContentClient.self] = .mainTabDeeplinkTestValue + self[UserClient.self] = .mainTabDeeplinkTestValue + self[AuthClient.self] = .mainTabDeeplinkTestValue + self[VersionClient.self] = .mainTabDeeplinkTestValue + self[UserDefaultsClient.self] = .mainTabDeeplinkTestValue + self[NotificationClient.self] = .mainTabDeeplinkTestValue + self[PasteboardClient.self] = .noop + self[DeeplinkRouteClient.self] = deeplinkRouteClient + } +} + +extension CategoryClient { + static let mainTabDeeplinkTestValue: Self = .init( + 카테고리_삭제: { _ in }, + 카테고리_수정: { categoryId, _ in + MainTabDeeplinkTestFixtures.categoryDetailResponse(categoryId: categoryId) + }, + 카테고리_목록_조회: { _, _, _ in + let delay = await MainTabDeeplinkRouteOrder.shared.nextDelayNanoseconds() + try? await Task.sleep(nanoseconds: delay) + return MainTabDeeplinkTestFixtures.categoryListResponse + }, + 카테고리_생성: { _ in + MainTabDeeplinkTestFixtures.categoryDetailResponse(categoryId: 2) + }, + 카테고리_프로필_목록_조회: { + MainTabDeeplinkTestFixtures.categoryImageResponses + }, + 유저_카테고리_개수_조회: { + MainTabDeeplinkTestFixtures.categoryCountResponse + }, + 카테고리_상세_조회: { categoryId in + MainTabDeeplinkTestFixtures.categoryDetailResponse( + categoryId: Int(categoryId) ?? 2 + ) + }, + 공유받은_카테고리_조회: { categoryId, _ in + MainTabDeeplinkTestFixtures.sharedCategoryResponse( + categoryId: Int(categoryId) ?? 2 + ) + }, + 공유받은_카테고리_저장: { _ in }, + 포킷_초대된_유저_목록_조회: { categoryId in + MainTabDeeplinkTestFixtures.invitedUserResponses(categoryId: categoryId) + }, + 포킷_내보내기: { _, _ in }, + 포킷_나가기: { _ in }, + 포킷_초대_수락: { _ in } + ) +} + +extension ContentClient { + static let mainTabDeeplinkTestValue: Self = .init( + 컨텐츠_삭제: { _ in }, + 컨텐츠_상세_조회: { contentId in + MainTabDeeplinkTestFixtures.contentDetailResponse( + contentId: Int(contentId) ?? 777 + ) + }, + 컨텐츠_수정: { contentId, _ in + MainTabDeeplinkTestFixtures.contentDetailResponse( + contentId: Int(contentId) ?? 777 + ) + }, + 컨텐츠_추가: { _ in + MainTabDeeplinkTestFixtures.contentDetailResponse(contentId: 777) + }, + 즐겨찾기: { contentId in + MainTabDeeplinkTestFixtures.bookmarkResponse( + contentId: Int(contentId) ?? 777 + ) + }, + 즐겨찾기_취소: { _ in }, + 카테고리_내_컨텐츠_목록_조회: { categoryId, _, _ in + MainTabDeeplinkTestFixtures.contentListResponse( + categoryId: Int(categoryId) ?? 2 + ) + }, + 미분류_카테고리_컨텐츠_조회: { _ in + MainTabDeeplinkTestFixtures.emptyContentListResponse + }, + 컨텐츠_검색: { _, _ in + MainTabDeeplinkTestFixtures.contentListResponse(categoryId: 2) + }, + 썸네일_수정: { _, _ in }, + 미분류_링크_포킷_이동: { _ in }, + 미분류_링크_삭제: { _ in }, + 추천_컨텐츠_조회: { _, _ in + MainTabDeeplinkTestFixtures.contentListResponse(categoryId: 2) + }, + 컨텐츠_신고사유_조회: { [ReportReasonResponse.mock] }, + 컨텐츠_신고: { _ in }, + 컨텐츠_신고_사유: { _, _ in } + ) +} + +extension NotificationClient { + static let mainTabDeeplinkTestValue: Self = .init( + 알림_목록_조회: { _ in MainTabDeeplinkTestFixtures.notificationListResponse }, + 알림_읽음: { _ in }, + 알림_삭제: { _ in } + ) +} + +extension UserClient { + static let mainTabDeeplinkTestValue: Self = .init( + 프로필_수정: { _ in MainTabDeeplinkTestFixtures.baseUserResponse }, + 닉네임_수정: { _ in MainTabDeeplinkTestFixtures.baseUserResponse }, + 회원등록: { _ in MainTabDeeplinkTestFixtures.baseUserResponse }, + 닉네임_중복_체크: { _ in MainTabDeeplinkTestFixtures.nicknameCheckResponse }, + 관심사_목록_조회: { MainTabDeeplinkTestFixtures.interests }, + 닉네임_조회: { MainTabDeeplinkTestFixtures.baseUserResponse }, + fcm_토큰_저장: { _ in MainTabDeeplinkTestFixtures.fcmResponse }, + 프로필_이미지_목록_조회: { MainTabDeeplinkTestFixtures.profileImages }, + 유저_관심사_목록_조회: { MainTabDeeplinkTestFixtures.interests }, + 관심사_수정: { _ in } + ) +} + +extension AuthClient { + static let mainTabDeeplinkTestValue: Self = .init( + 로그인: { _ in MainTabDeeplinkTestFixtures.tokenResponse }, + 회원탈퇴: { _ in }, + 토큰재발급: { _ in ReissueResponse(accessToken: "uitest-access-token") }, + apple: { _ in MainTabDeeplinkTestFixtures.appleTokenResponse }, + appleRevoke: { _, _ in } + ) +} + +extension VersionClient { + static let mainTabDeeplinkTestValue: Self = .init( + 버전체크: { MainTabDeeplinkTestFixtures.versionResponse } + ) +} + +extension UserDefaultsClient { + static let mainTabDeeplinkTestValue: Self = .init( + boolKey: { _ in false }, + stringKey: { _ in nil }, + stringArrayKey: { _ in nil }, + removeBool: { _ in }, + removeString: { _ in }, + removeStringArray: { _ in }, + setBool: { _, _ in }, + setString: { _, _ in }, + setStringArray: { _, _ in } + ) +} + +private actor MainTabDeeplinkRouteOrder { + static let shared = MainTabDeeplinkRouteOrder() + + private var requestCount = 0 + + func nextDelayNanoseconds() -> UInt64 { + requestCount += 1 + return UInt64(requestCount) * 30_000_000 + } +} + +private enum MainTabDeeplinkTestFixtures { + private static let category2ID = 2 + private static let category3ID = 3 + private static let category2Name = "UITest-Category-2" + private static let category3Name = "UITest-Category-3" + + static let interests: [InterestResponse] = [ + .init(code: "it", description: "IT"), + .init(code: "design", description: "디자인") + ] + + static let categoryListResponse: CategoryListInquiryResponse = decode( + [ + "data": [ + categoryItem( + id: category2ID, + name: category2Name, + contentCount: 2, + userCount: 2 + ), + categoryItem( + id: category3ID, + name: category3Name, + contentCount: 1, + userCount: 2 + ) + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + + static let categoryImageResponses: [CategoryImageResponse] = decode( + [ + ["imageId": 2002, "imageUrl": "https://example.com/category-2.png"], + ["imageId": 2003, "imageUrl": "https://example.com/category-3.png"] + ] + ) + + static let categoryCountResponse: CategoryCountResponse = decode( + ["categoryTotalCount": 2] + ) + + static let baseUserResponse: BaseUserResponse = decode( + [ + "id": 100, + "email": "uitest@pokit.app", + "nickname": "UITestUser", + "profileImage": ["id": 10, "url": "https://example.com/profile.png"] + ] + ) + + static let nicknameCheckResponse: NicknameCheckResponse = decode( + ["isDuplicate": false] + ) + + static let profileImages: [BaseProfileImageResponse] = decode( + [ + ["id": 10, "url": "https://example.com/profile.png"] + ] + ) + + static let fcmResponse: FCMResponse = decode( + ["userId": 100, "token": "uitest-fcm-token"] + ) + + static let tokenResponse: TokenResponse = decode( + [ + "accessToken": "uitest-access-token", + "refreshToken": "uitest-refresh-token", + "isRegistered": true + ] + ) + + static let appleTokenResponse: AppleTokenResponse = decode( + ["refresh_token": "uitest-apple-refresh-token"] + ) + + static let versionResponse: VersionResponse = decode( + [ + "results": [ + ["version": "1.0.0", "trackId": 2415354644] + ] + ] + ) + + static let notificationListResponse: NotificationListInquiryResponse = decode( + [ + "data": [ + [ + "id": 1, + "notificationType": "LINK_ADDED", + "title": "'뜨개질' 포킷에 링크가 추가되었어요", + "body": "OO님이 추가한 링크를 지금 확인해보세요", + "categoryImageUrl": "https://example.com/category-2.png", + "isRead": false, + "navigationType": "CONTENT_DETAIL", + "deepLink": "pokit://shared?categoryId=2&contentId=777", + "createdAt": "2026-02-18T00:00:00Z" + ] + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + + static let emptyContentListResponse: ContentListInquiryResponse = decode( + [ + "data": [], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + + static func categoryDetailResponse(categoryId: Int) -> CategoryEditResponse { + decode( + [ + "categoryId": categoryId, + "categoryName": categoryName(for: categoryId), + "categoryImage": [ + "imageId": 2000 + categoryId, + "imageUrl": "https://example.com/category-\(categoryId).png" + ], + "alertEnabled": true + ] + ) + } + + static func sharedCategoryResponse(categoryId: Int) -> SharedCategoryResponse { + decode( + [ + "category": [ + "categoryId": categoryId, + "categoryName": categoryName(for: categoryId), + "contentCount": 1, + "categoryImageId": 2000 + categoryId, + "categoryImageUrl": "https://example.com/category-\(categoryId).png" + ], + "contents": [ + "data": [ + [ + "contentId": 777, + "data": "https://example.com/777", + "domain": "example.com", + "title": "UITest-Content-777", + "memo": "uitest memo", + "thumbNail": "https://example.com/thumb-777.png", + "createdAt": "2026-02-18T00:00:00Z", + "authorUserId": 100, + "authorNickname": "UITestUser", + "authorProfileImageURL": "https://example.com/profile.png" + ] + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ] + ) + } + + static func invitedUserResponses(categoryId: Int) -> [InvitedUserResponse] { + decode( + [ + [ + "userId": 1000 + categoryId, + "nickname": "Owner-\(categoryId)", + "profileImage": [ + "id": 1000 + categoryId, + "url": "https://example.com/owner-\(categoryId).png" + ] + ], + [ + "userId": 2000 + categoryId, + "nickname": "Member-\(categoryId)A", + "profileImage": NSNull() + ] + ] + ) + } + + static func contentListResponse(categoryId: Int) -> ContentListInquiryResponse { + switch categoryId { + case category2ID: + return decode( + [ + "data": [ + contentBase( + contentId: 777, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-777" + ), + contentBase( + contentId: 778, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-778" + ) + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + case category3ID: + return decode( + [ + "data": [ + contentBase( + contentId: 888, + categoryId: category3ID, + categoryName: category3Name, + title: "UITest-Content-888" + ) + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + default: + return emptyContentListResponse + } + } + + static func contentDetailResponse(contentId: Int) -> ContentDetailResponse { + switch contentId { + case 777: + return decode( + contentDetail( + contentId: 777, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-777" + ) + ) + case 778: + return decode( + contentDetail( + contentId: 778, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-778" + ) + ) + case 888: + return decode( + contentDetail( + contentId: 888, + categoryId: category3ID, + categoryName: category3Name, + title: "UITest-Content-888" + ) + ) + default: + return decode( + contentDetail( + contentId: contentId, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-\(contentId)" + ) + ) + } + } + + static func bookmarkResponse(contentId: Int) -> BookmarkResponse { + decode(["contentId": contentId]) + } + + private static func categoryName(for id: Int) -> String { + switch id { + case category2ID: return category2Name + case category3ID: return category3Name + default: return "UITest-Category-\(id)" + } + } + + private static func sort() -> [String: Any] { + [ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ] + } + + private static func categoryItem( + id: Int, + name: String, + contentCount: Int, + userCount: Int + ) -> [String: Any] { + [ + "categoryId": id, + "userId": 100, + "categoryName": name, + "categoryImage": [ + "imageId": 2000 + id, + "imageUrl": "https://example.com/category-\(id).png" + ], + "contentCount": contentCount, + "createdAt": "2026-02-18T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "default", + "userCount": userCount, + "isFavorite": false, + "alertEnabled": true + ] + } + + private static func contentBase( + contentId: Int, + categoryId: Int, + categoryName: String, + title: String + ) -> [String: Any] { + [ + "contentId": contentId, + "category": [ + "categoryId": categoryId, + "categoryName": categoryName + ], + "data": "https://example.com/\(contentId)", + "domain": "example.com", + "title": title, + "memo": "memo-\(contentId)", + "thumbNail": "https://example.com/thumb-\(contentId).png", + "createdAt": "2026-02-18T00:00:00Z", + "isRead": false, + "isFavorite": false, + "keyword": NSNull(), + "author": [ + "userId": 100, + "nickname": "UITestUser", + "profileImageUrl": "https://example.com/profile.png" + ], + "memoExists": true + ] + } + + private static func contentDetail( + contentId: Int, + categoryId: Int, + categoryName: String, + title: String + ) -> [String: Any] { + [ + "contentId": contentId, + "category": [ + "categoryId": categoryId, + "categoryName": categoryName + ], + "data": "https://example.com/\(contentId)", + "title": title, + "memo": "memo-\(contentId)", + "alertYn": "NO", + "createdAt": "2026-02-18T00:00:00Z", + "favorites": false, + "keyword": "예능", + "userNickname": "UITestUser", + "author": [ + "userId": 100, + "nickname": "UITestUser", + "profileImageUrl": "https://example.com/profile.png" + ] + ] + } + + private static func decode(_ jsonObject: Any) -> T { + do { + let data = try JSONSerialization.data(withJSONObject: jsonObject) + return try JSONDecoder().decode(T.self, from: data) + } catch { + fatalError("UITest fixture decode failed for \(T.self): \(error)") + } + } +} diff --git a/Projects/App/AppUITests/Resources/info.plist b/Projects/App/AppUITests/Resources/info.plist new file mode 100644 index 00000000..b278c65f --- /dev/null +++ b/Projects/App/AppUITests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/App/AppUITests/Sources/MainTabDeeplinkUITests.swift b/Projects/App/AppUITests/Sources/MainTabDeeplinkUITests.swift new file mode 100644 index 00000000..13d76e84 --- /dev/null +++ b/Projects/App/AppUITests/Sources/MainTabDeeplinkUITests.swift @@ -0,0 +1,206 @@ +// +// MainTabDeeplinkUITests.swift +// AppUITests +// +// Created by 김도형 on 2/18/26. +// + +import XCTest + +final class MainTabDeeplinkUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func test_앱이_실행되고_기본화면이_표시된다() { + let app = XCUIApplication() + app.launch() + + let candidates: [XCUIElement] = [ + app.otherElements["main-tab-root"].firstMatch, + app.buttons["Apple로 계속하기"].firstMatch, + app.buttons["Google로 계속하기"].firstMatch + ] + + let found = candidates.contains { $0.waitForExistence(timeout: 8) } + XCTAssertTrue(found, "앱의 기본 화면(main tab 또는 로그인)이 표시되지 않았습니다.") + } + + func test_카카오_openurl_카테고리_공유딥링크는_해당카테고리로_이동한다() { + let app = launchDeeplinkApp( + deeplinks: [ + "kakao7890f93caf1d9d5da976da4b4bc6e5e7://kakaolink?categoryId=2&shareType=share" + ] + ) + + waitForElement(element(in: app, id: "category-detail-2"), in: app) + } + + func test_포킷_alert_push는_알림함으로_이동한다() { + let app = launchDeeplinkApp( + deeplinks: ["pokit://alert"] + ) + + waitForElement(element(in: app, id: "main-tab-root"), in: app) + waitForElement(app.staticTexts["알림함"].firstMatch, in: app) + } + + func test_포킷_push_컨텐츠아이디가_있는_공유딥링크는_상세시트를_열고_닫은뒤_777카드를_확인한다() { + let app = launchDeeplinkApp( + deeplinks: ["pokit://shared?categoryId=2&contentId=777"] + ) + + waitForElement(element(in: app, id: "category-detail-2"), in: app) + + let contentSheet = app.scrollViews["content-detail-sheet"].firstMatch + waitForElement(contentSheet, in: app) + + dismissSheet(contentSheet, in: app) + waitForElement(element(in: app, id: "content-card-777"), in: app) + } + + func test_포킷_push_유저아이디가_있는_공유딥링크는_참여인원시트를_연다() { + let app = launchDeeplinkApp( + deeplinks: ["pokit://shared?categoryId=2&userId=999"] + ) + + waitForElement(element(in: app, id: "category-detail-2"), in: app) + waitForElement(element(in: app, id: "participants-sheet"), in: app) + } + + func test_앱실행직후_포킷_push_딥링크를_넣어도_정상_라우팅된다() { + let app = launchDeeplinkApp( + deeplinks: ["pokit://shared?categoryId=2"], + routeBeforeMainTab: true + ) + + waitForElement(element(in: app, id: "category-detail-2"), in: app) + } + + func test_같은카테고리로_재라우팅하면_중복푸시되지_않는다() { + let app = launchDeeplinkApp( + deeplinks: [ + "pokit://shared?categoryId=2", + "pokit://shared?categoryId=2" + ] + ) + + let categoryDetail = element(in: app, id: "category-detail-2") + waitForElement(categoryDetail, in: app) + + let backButton = app.buttons["category-detail-back"].firstMatch + waitForElement(backButton, in: app) + + backButton.tap() + + waitForElement(element(in: app, id: "main-tab-root"), in: app) + waitForAbsence(categoryDetail, in: app) + } + + func test_다른카테고리로_재라우팅하면_스택에_추가이동된다() { + let app = launchDeeplinkApp( + deeplinks: [ + "pokit://shared?categoryId=2", + "pokit://shared?categoryId=3" + ] + ) + + waitForElement(element(in: app, id: "category-detail-3"), in: app) + + let backButton = app.buttons["category-detail-back"].firstMatch + waitForElement(backButton, in: app) + + backButton.tap() + + waitForElement(element(in: app, id: "category-detail-2"), in: app) + } +} + +private extension MainTabDeeplinkUITests { + func launchDeeplinkApp( + deeplinks: [String], + routeBeforeMainTab: Bool = false, + forceMainTab: Bool = true + ) -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["UITEST_MODE"] = "1" + app.launchEnvironment["UITEST_SCENARIO"] = "deeplink" + app.launchEnvironment["UITEST_FORCE_MAIN_TAB"] = forceMainTab ? "1" : "0" + app.launchEnvironment["UITEST_ROUTE_BEFORE_MAIN_TAB"] = routeBeforeMainTab ? "1" : "0" + app.launchEnvironment["UITEST_DEEPLINKS_JSON"] = deeplinkJSONString(from: deeplinks) + app.launch() + return app + } + + func element(in app: XCUIApplication, id: String) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: id).firstMatch + } + + @discardableResult + func waitForElement( + _ element: XCUIElement, + in app: XCUIApplication, + timeout: TimeInterval = 8, + file: StaticString = #filePath, + line: UInt = #line + ) -> Bool { + let exists = element.waitForExistence(timeout: timeout) + if !exists { + XCTFail( + """ + Element not found: \(element.identifier) + + Hierarchy: + \(app.debugDescription) + """, + file: file, + line: line + ) + } + return exists + } + + func waitForAbsence( + _ element: XCUIElement, + in app: XCUIApplication, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line + ) { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + if result != .completed { + XCTFail( + """ + Element did not disappear: \(element.identifier) + + Hierarchy: + \(app.debugDescription) + """, + file: file, + line: line + ) + } + } + + func dismissSheet(_ element: XCUIElement, in app: XCUIApplication, maxSwipes: Int = 4) { + guard element.exists else { return } + + for _ in 0.. String { + guard + let data = try? JSONSerialization.data(withJSONObject: deeplinks, options: []), + let json = String(data: data, encoding: .utf8) + else { + return "[]" + } + return json + } +} diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 3a6b76a7..ce80d911 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -83,6 +83,17 @@ let appTestTarget: Target = .makeTarget( ] ) +let appUITestTarget: Target = .makeTarget( + name: "AppUITests", + product: .uiTests, + bundleName: "AppUITests", + infoPlist: .dictionary(["ENABLE_TESTING_SEARCH_PATHS": "YES"]), + resources: ["AppUITests/Resources/**"], + dependencies: [ + .target(projectTarget) + ] +) + let project = Project( name: "App", options: .options( @@ -92,6 +103,7 @@ let project = Project( targets: [ projectTarget, appTestTarget, + appUITestTarget, shareExtensionTarget ] ) diff --git a/Projects/App/Sources/AppDelegate/AppDelegate+UITest.swift b/Projects/App/Sources/AppDelegate/AppDelegate+UITest.swift new file mode 100644 index 00000000..30dd94cb --- /dev/null +++ b/Projects/App/Sources/AppDelegate/AppDelegate+UITest.swift @@ -0,0 +1,95 @@ +// +// AppDelegate+UITest.swift +// App +// +// Created by Codex on 2026-03-22. +// + +#if DEBUG +import Foundation + +import ComposableArchitecture +import CoreKit +import Dependencies + +extension AppDelegate { + static var shouldSkipLaunchAnalytics: Bool { + UITestLaunchConfig.current.isEnabled + } + + static func makeUITestStore() -> StoreOf { + Store(initialState: AppDelegateFeature.State()) { + AppDelegateFeature() + } withDependencies: { + $0.applyAppMainTabDeeplinkTestDependencies() + } + } +} + +struct UITestLaunchConfig: Sendable { + enum Scenario: String, Sendable { + case deeplink + } + + static let modeKey = "UITEST_MODE" + static let scenarioKey = "UITEST_SCENARIO" + static let forceMainTabKey = "UITEST_FORCE_MAIN_TAB" + static let routeBeforeMainTabKey = "UITEST_ROUTE_BEFORE_MAIN_TAB" + static let deeplinksJSONKey = "UITEST_DEEPLINKS_JSON" + + let isEnabled: Bool + let scenario: Scenario? + let shouldForceMainTab: Bool + let routeBeforeMainTab: Bool + let deeplinkURLs: [URL] + + static let current = UITestLaunchConfig(environment: ProcessInfo.processInfo.environment) + + init(environment: [String: String]) { + self.isEnabled = Self.boolValue(for: Self.modeKey, in: environment) + self.scenario = environment[Self.scenarioKey].flatMap(Scenario.init(rawValue:)) + self.shouldForceMainTab = Self.boolValue(for: Self.forceMainTabKey, in: environment) + self.routeBeforeMainTab = Self.boolValue(for: Self.routeBeforeMainTabKey, in: environment) + self.deeplinkURLs = Self.parseDeeplinkURLs(environment[Self.deeplinksJSONKey]) + } + + var shouldRunDeeplinkScenario: Bool { + self.isEnabled && self.scenario == .deeplink && !self.deeplinkURLs.isEmpty + } + + private static func boolValue(for key: String, in environment: [String: String]) -> Bool { + guard let value = environment[key]?.lowercased() else { + return false + } + + switch value { + case "1", "true", "yes", "y": + return true + default: + return false + } + } + + private static func parseDeeplinkURLs(_ rawValue: String?) -> [URL] { + guard let rawValue, !rawValue.isEmpty else { + return [] + } + + if + let data = rawValue.data(using: .utf8), + let urls = try? JSONDecoder().decode([String].self, from: data) + { + return urls.compactMap(URL.init(string:)).filter { $0.scheme?.isEmpty == false } + } + + if + let url = URL(string: rawValue), + url.scheme?.isEmpty == false + { + return [url] + } + + return [] + } +} +#endif diff --git a/Projects/App/Sources/AppDelegate/AppDelegate.swift b/Projects/App/Sources/AppDelegate/AppDelegate.swift index c51a2024..98c48473 100644 --- a/Projects/App/Sources/AppDelegate/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate/AppDelegate.swift @@ -9,6 +9,7 @@ import SwiftUI import UIKit import ComposableArchitecture +import CoreKit import Firebase import FirebaseMessaging import GoogleSignIn @@ -17,9 +18,19 @@ import Dependencies final class AppDelegate: NSObject { @Dependency(\.amplitude) private var amplitude - - let store = Store(initialState: AppDelegateFeature.State()) { - AppDelegateFeature() + + let store: StoreOf + + override init() { +#if DEBUG + if UITestLaunchConfig.current.isEnabled { + self.store = Self.makeUITestStore() + return + } +#endif + self.store = Store(initialState: AppDelegateFeature.State()) { + AppDelegateFeature() + } } } //MARK: - UIApplicationDelegate @@ -37,6 +48,12 @@ extension AppDelegate: UIApplicationDelegate { ) -> Bool { self.store.send(.didFinishLaunching) +#if DEBUG + if Self.shouldSkipLaunchAnalytics { + return true + } +#endif + // 운영체제 버전 (ex: "iOS 18.0.0") let osVersion = "iOS \(UIDevice.current.systemVersion)" diff --git a/Projects/App/Sources/AppDelegate/AppDelegateFeature+UITest.swift b/Projects/App/Sources/AppDelegate/AppDelegateFeature+UITest.swift new file mode 100644 index 00000000..7d587589 --- /dev/null +++ b/Projects/App/Sources/AppDelegate/AppDelegateFeature+UITest.swift @@ -0,0 +1,41 @@ +// +// AppDelegateFeature+UITest.swift +// App +// +// Created by Codex on 3/22/26. +// + +#if DEBUG +import Foundation + +import ComposableArchitecture +import CoreKit + +extension AppDelegateFeature { + func handleDidFinishLaunchingForUITest() -> Effect? { + let config = UITestLaunchConfig.current + guard config.isEnabled else { return nil } + + return .run { send in + if config.shouldRunDeeplinkScenario, config.routeBeforeMainTab { + await self.routeUITestDeeplinks(config.deeplinkURLs) + } + + if config.shouldForceMainTab { + await send(.root(._sceneChange(.mainTab()))) + } + + if config.shouldRunDeeplinkScenario, !config.routeBeforeMainTab { + await self.routeUITestDeeplinks(config.deeplinkURLs) + } + } + } + + private func routeUITestDeeplinks(_ deeplinkURLs: [URL]) async { + guard !deeplinkURLs.isEmpty else { return } + for deeplinkURL in deeplinkURLs { + await self.deeplinkRouter.routeTo(deeplinkURL) + } + } +} +#endif diff --git a/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift b/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift index 2bb9afe9..e96eddef 100644 --- a/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift +++ b/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift @@ -17,11 +17,11 @@ public struct AppDelegateFeature { @Dependency(UserNotificationClient.self) var userNotifications @Dependency(RemoteNotificationsClient.self) var registerForRemoteNotifications @Dependency(UserDefaultsClient.self) var userDefaults + @Dependency(DeeplinkRouteClient.self) var deeplinkRouter @ObservableState - public struct State { + public struct State: Equatable { public var root = RootFeature.State() - @Shared(.inMemory("PushTapped")) var isPushTapped: Bool = false public init() {} } @@ -39,9 +39,14 @@ public struct AppDelegateFeature { Scope(state: \.root, action: \.root) { RootFeature() } - Reduce { state, action in + Reduce { _, action in switch action { case .didFinishLaunching: +#if DEBUG + if let effect = self.handleDidFinishLaunchingForUITest() { + return effect + } +#endif FirebaseApp.configure() let userNotificationsEventStream = self.userNotifications.delegate() if let kakaoAppKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String { @@ -77,12 +82,22 @@ public struct AppDelegateFeature { case let .userNotifications(.willPresentNotification(_, completionHandler)): return .run { _ in completionHandler(.banner) } - case let .userNotifications(.didReceiveResponse(_, completionHandler)): - state.isPushTapped = true - return .run { @MainActor _ in completionHandler() } + case let .userNotifications(.didReceiveResponse(response, completionHandler)): + let content = response.notification.request.content + let deeplinkURL = { + guard let deepLink = content.userInfo["deepLink"] as? String, + let url = URL(string: deepLink), + url.scheme?.isEmpty == false + else { return URL(string: "pokit://alert") } + return url + }() + + return .run { _ in + await completionHandler() + await deeplinkRouter.routeTo(deeplinkURL) + } case .userNotifications: return .none - case .root: return .none } diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index 57efe647..a4e42bd7 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -23,8 +23,8 @@ public struct MainTabFeature { private var pasteBoard @Dependency(CategoryClient.self) private var categoryClient - @Dependency(UserDefaultsClient.self) - private var userDefaults + @Dependency(DeeplinkRouteClient.self) + private var deeplinkRouter @Dependency(\.amplitude.track) private var amplitudeTrack @@ -44,7 +44,6 @@ public struct MainTabFeature { var recommend: RecommendFeature.State = .init() @Presents var contentDetail: ContentDetailFeature.State? @Shared(.inMemory("SelectCategory")) var categoryId: Int? - @Shared(.inMemory("PushTapped")) var isPushTapped: Bool = false var categoryOfSavedContent: BaseCategoryItem? public init() { @@ -52,9 +51,9 @@ public struct MainTabFeature { } } /// - Action + @CasePathable public enum Action: FeatureAction, BindableAction, ViewAction { case binding(BindingAction) - case pushAlertTapped(Bool) case view(View) case inner(InnerAction) case async(AsyncAction) @@ -77,19 +76,26 @@ public struct MainTabFeature { case 검색_버튼_눌렀을때 case 알림_버튼_눌렀을때 } + @CasePathable public enum InnerAction: Equatable { case 링크추가및수정이동(contentId: Int) case linkCopySuccess(URL?) case 공유받은_카테고리_이동(category: BaseCategoryItem, type: CategoryType) + case 포킷_딥링크_이동(category: BaseCategoryItem, contentId: Int?, userId: Int?) + case 딥링크_수신(DeeplinkRoute) case 경고_띄움(BaseError) case errorSheetPresented(Bool) case 링크팝업_활성화(PokitLinkPopup.PopupType) case 카테고리상세_이동(category: BaseCategoryItem) } + @CasePathable public enum AsyncAction: Equatable { case 공유받은_카테고리_조회(categoryId: Int, shareType: String?) + case 포킷_딥링크_처리(categoryId: Int, contentId: Int?, userId: Int?) } + @CasePathable public enum ScopeAction: Equatable { case doNothing } + @CasePathable public enum DelegateAction: Equatable { case 링크추가하기 case 포킷추가하기 @@ -100,6 +106,12 @@ public struct MainTabFeature { } /// initiallizer public init() {} + + private enum CancelID { + case 클립보드_감지 + case 딥링크_스트림_감지 + } + /// - Reducer Core private func core(into state: inout State, action: Action) -> Effect { switch action { @@ -117,12 +129,6 @@ public struct MainTabFeature { return .none case .binding: return .none - case let .pushAlertTapped(isTapped): - if isTapped { - return .send(.delegate(.알림함이동)) - } else { - return .none - } /// - View case .view(let viewAction): return handleViewAction(viewAction, state: &state) @@ -184,42 +190,26 @@ private extension MainTabFeature { return linkPopupButtonTapped(state: &state) case .onAppear: - if state.isPushTapped { - return .send(.pushAlertTapped(true)) - } return .merge( .run { send in for await _ in self.pasteBoard.changes() { let url = try await pasteBoard.probableWebURL() await send(.inner(.linkCopySuccess(url)), animation: .pokitSpring) } - }, - .publisher { - state.$isPushTapped.publisher - .map(Action.pushAlertTapped) } + .cancellable(id: CancelID.클립보드_감지, cancelInFlight: true), + .run { send in + for await route in self.deeplinkRouter.routeStream() { + await send(.inner(.딥링크_수신(route)), animation: .smooth) + } + } + .cancellable(id: CancelID.딥링크_스트림_감지, cancelInFlight: true) ) case .onOpenURL(url: let url): - guard - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - else { return .none } - - let queryItems = components.queryItems ?? [] - guard - let categoryIdString = queryItems.first(where: { $0.name == "categoryId" })?.value, - let categoryId = Int(categoryIdString) - else { return .none } - - let shareType = queryItems.first(where: { $0.name == "shareType" })?.value - - switch state.selectedTab { - case .pokit: - amplitudeTrack(.view_home_pokit(entryPoint: "deeplink")) - case .recommend: - amplitudeTrack(.view_home_recommend(entryPoint: "deeplink")) + guard url.scheme?.lowercased().hasPrefix("kakao") == true else { return .none } + return .run { _ in + await self.deeplinkRouter.routeTo(url) } - - return .send(.async(.공유받은_카테고리_조회(categoryId: categoryId, shareType: shareType))) case .경고_확인버튼_클릭: state.error = nil return .run { send in await send(.inner(.errorSheetPresented(false))) } @@ -258,11 +248,11 @@ private extension MainTabFeature { ) state.link = url.absoluteString return .none - + case let .경고_띄움(error): state.error = error return .run { send in await send(.inner(.errorSheetPresented(true))) } - + case let .errorSheetPresented(isPresented): state.isErrorSheetPresented = isPresented return .none @@ -272,17 +262,76 @@ private extension MainTabFeature { return .none case let .카테고리상세_이동(category): if category.categoryName == Constants.미분류 { - state.selectedTab = .pokit state.path.removeAll() return .send(.pokit(.delegate(.미분류_카테고리_활성화))) } state.path.append(.카테고리상세(.init(category: category))) return .none - + case let .공유받은_카테고리_이동(category, type): + if let context = topCategoryContext(from: state), context.categoryId == category.id { + return refreshCategoryDetail( + stackElementId: context.stackElementId, + type: type + ) + } state.path.append(.카테고리상세(.init(type: type, category: category))) return .none - + + case let .포킷_딥링크_이동(category, contentId, userId): + state.contentDetail = nil + + if let context = topCategoryContext(from: state), context.categoryId == category.id { + let refreshEffect = refreshCategoryDetail( + stackElementId: context.stackElementId, + type: CategoryType.참여 + ) + + if let contentId { + state.contentDetail = ContentDetailFeature.State(contentId: contentId) + return refreshEffect + } + + guard userId != nil else { return refreshEffect } + return .concatenate( + refreshEffect, + openParticipantsSheet(stackElementId: context.stackElementId) + ) + } + + state.path.append(.카테고리상세(.init(type: .참여, category: category))) + guard let stackElementId = state.path.ids.last else { return .none } + + if let contentId { + state.contentDetail = ContentDetailFeature.State(contentId: contentId) + return .none + } + + guard userId != nil else { return .none } + return openParticipantsSheet(stackElementId: stackElementId) + + case let .딥링크_수신(route): + switch route { + case let .kakaoSharedCategory(categoryId, shareType): + switch state.selectedTab { + case .pokit: + amplitudeTrack(.view_home_pokit(entryPoint: "deeplink")) + case .recommend: + amplitudeTrack(.view_home_recommend(entryPoint: "deeplink")) + } + return .send(.async(.공유받은_카테고리_조회(categoryId: categoryId, shareType: shareType))) + + case let .pokitShared(categoryId, contentId, userId): + guard let categoryId else { return .none } + return .send(.async(.포킷_딥링크_처리( + categoryId: categoryId, + contentId: contentId, + userId: userId + ))) + case .pokitAlert: + return .send(.delegate(.알림함이동)) + } + default: return .none } } @@ -317,6 +366,39 @@ private extension MainTabFeature { await send(.inner(.경고_띄움(errorDomain))) } } + + case let .포킷_딥링크_처리(categoryId, contentId, userId): + return .run { send in + do { + let request = BasePageableRequest(page: 0, size: 30, sort: ["createdAt,desc"]) + let list = try await categoryClient.카테고리_목록_조회(request, false, false).toDomain() + if let category = list.data?.first(where: { $0.id == categoryId }) { + await send(.inner(.포킷_딥링크_이동(category: category, contentId: contentId, userId: userId)), animation: .smooth) + return + } + + let response = try await categoryClient.카테고리_상세_조회("\(categoryId)") + let category = BaseCategoryItem( + id: response.categoryId, + userId: 0, + categoryName: response.categoryName, + categoryImage: response.categoryImage.toDomain(), + contentCount: 0, + createdAt: "", + openType: .공개, + keywordType: .default, + userCount: 0, + isFavorite: false, + alertEnabled: response.alertEnabled + ) + + await send(.inner(.포킷_딥링크_이동(category: category, contentId: contentId, userId: userId)), animation: .smooth) + } catch { + guard let errorResponse = error as? ErrorResponse else { return } + let errorDomain = BaseError(response: errorResponse) + await send(.inner(.경고_띄움(errorDomain))) + } + } } } /// - Scope Effect @@ -342,4 +424,47 @@ private extension MainTabFeature { return .none } } + + func topCategoryContext(from state: State) -> ( + stackElementId: StackElementID, + categoryId: Int + )? { + guard + let stackElementId = state.path.ids.last, + case let .카테고리상세(categoryDetailState) = state.path.last + else { return nil } + + return (stackElementId, categoryDetailState.category.id) + } + + func refreshCategoryDetail( + stackElementId: StackElementID, + type: CategoryType + ) -> Effect { + .concatenate( + .send(.path(.element( + id: stackElementId, + action: .카테고리상세(.inner(.타입_변경(type))) + ))), + .send(.path(.element( + id: stackElementId, + action: .카테고리상세(.inner(.pagenation_초기화)) + ))), + .send(.path(.element( + id: stackElementId, + action: .카테고리상세(.async(.카테고리_내_컨텐츠_목록_조회_API)) + ))), + .send(.path(.element( + id: stackElementId, + action: .카테고리상세(.async(.포킷_초대된_유저_목록_조회_API)) + ))) + ) + } + + func openParticipantsSheet(stackElementId: StackElementID) -> Effect { + .send(.path(.element( + id: stackElementId, + action: .카테고리상세(.view(.참여인원_버튼_눌렀을때)) + ))) + } } diff --git a/Projects/App/Sources/MainTab/MainTabFeatureView.swift b/Projects/App/Sources/MainTab/MainTabFeatureView.swift index bc98f4c8..a794cfee 100644 --- a/Projects/App/Sources/MainTab/MainTabFeatureView.swift +++ b/Projects/App/Sources/MainTab/MainTabFeatureView.swift @@ -75,6 +75,8 @@ public extension MainTabView { } } } + .accessibilityIdentifier("main-tab-root") + .task { await send(.onAppear).finish() } .onOpenURL { send(.onOpenURL(url: $0)) } } } @@ -106,6 +108,7 @@ private extension MainTabView { ) ) { store in ContentDetailView(store: store) + .accessibilityIdentifier("content-detail-sheet") } .sheet(isPresented: $store.isErrorSheetPresented) { PokitAlert( @@ -115,7 +118,6 @@ private extension MainTabView { action: { send(.경고_확인버튼_클릭) } ) } - .task { await send(.onAppear).finish() } } var tabView: some View { diff --git a/Projects/App/Sources/MainTab/MainTabPath.swift b/Projects/App/Sources/MainTab/MainTabPath.swift index 98c31724..7630a654 100644 --- a/Projects/App/Sources/MainTab/MainTabPath.swift +++ b/Projects/App/Sources/MainTab/MainTabPath.swift @@ -30,6 +30,7 @@ public struct MainTabPath { case 링크목록(ContentListFeature.State) } + @CasePathable public enum Action { case 알림함(PokitAlertBoxFeature.Action) case 검색(PokitSearchFeature.Action) @@ -59,7 +60,6 @@ public extension MainTabFeature { case .pokit(.delegate(.alertButtonTapped)), .recommend(.delegate(.알림_버튼_눌렀을때)), .delegate(.알림함이동): - state.isPushTapped = false state.path.append(.알림함(PokitAlertBoxFeature.State())) return .none @@ -90,7 +90,8 @@ public extension MainTabFeature { case .delegate(.포킷추가하기), .path(.element(_, action: .링크추가및수정(.delegate(.포킷추가하기)))), .pokit(.delegate(.포킷추가_버튼_눌렀을때)), - .recommend(.delegate(.포킷_추가하기_버튼_눌렀을때)): + .recommend(.delegate(.포킷_추가하기_버튼_눌렀을때)), + .contentDetail(.presented(.delegate(.포킷_추가하기_버튼_눌렀을때))): state.path.append(.포킷추가및수정(PokitCategorySettingFeature.State(type: .추가))) return .none @@ -119,7 +120,10 @@ public extension MainTabFeature { let .path(.element(_, action: .링크목록(.delegate(.링크상세(content: content))))), let .path(.element(_, action: .검색(.delegate(.linkCardTapped(content: content))))): - state.contentDetail = ContentDetailFeature.State(contentId: content.id) + state.contentDetail = ContentDetailFeature.State( + contentId: content.id, + authorUserId: content.authorUserId + ) return .none /// - 링크상세 바텀시트에서 링크수정으로 이동 @@ -127,8 +131,7 @@ public extension MainTabFeature { let .pokit(.delegate(.링크수정하기(id))), let .path(.element(_, action: .카테고리상세(.delegate(.링크수정(id))))), let .path(.element(_, action: .링크목록(.delegate(.링크수정(id))))), - let .path(.element(_, action: .검색(.delegate(.링크수정(id))))), - let .path(.element(_, action: .알림함(.delegate(.moveToContentEdit(id))))): + let .path(.element(_, action: .검색(.delegate(.링크수정(id))))): return .run { send in await send(.inner(.링크추가및수정이동(contentId: id))) } /// - 컨텐츠 상세보기 내부 액션 실행 diff --git a/Projects/App/Sources/PokitApp.swift b/Projects/App/Sources/PokitApp.swift index 9f17f118..c92b8a6f 100644 --- a/Projects/App/Sources/PokitApp.swift +++ b/Projects/App/Sources/PokitApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import ComposableArchitecture @@ -17,8 +18,10 @@ struct PokitApp: App { var body: some Scene { WindowGroup { - WithPerceptionTracking { - RootView(store: store.scope(state: \.root, action: \.root)) + if !_XCTIsTesting { + WithPerceptionTracking { + RootView(store: store.scope(state: \.root, action: \.root)) + } } } } diff --git a/Projects/App/Sources/Root/RootFeature.swift b/Projects/App/Sources/Root/RootFeature.swift index f372d349..e34a62fd 100644 --- a/Projects/App/Sources/Root/RootFeature.swift +++ b/Projects/App/Sources/Root/RootFeature.swift @@ -21,7 +21,7 @@ public struct RootFeature { } @ObservableState - public enum State { + public enum State: Equatable { case intro(IntroFeature.State = .init()) case mainTab(MainTabFeature.State = .init()) diff --git a/Projects/App/Sources/UITestSupport/MainTabDeeplinkTestDependencies.swift b/Projects/App/Sources/UITestSupport/MainTabDeeplinkTestDependencies.swift new file mode 100644 index 00000000..77333a63 --- /dev/null +++ b/Projects/App/Sources/UITestSupport/MainTabDeeplinkTestDependencies.swift @@ -0,0 +1,567 @@ +// +// MainTabDeeplinkTestDependencies.swift +// App +// +// Created by Codex on 2026-03-22. +// + +#if DEBUG +import Foundation + +import CoreKit +import Dependencies + +extension DependencyValues { + mutating func applyAppMainTabDeeplinkTestDependencies( + deeplinkRouteClient: DeeplinkRouteClient = .liveValue + ) { + self[CategoryClient.self] = .appMainTabDeeplinkTestValue + self[ContentClient.self] = .appMainTabDeeplinkTestValue + self[UserClient.self] = .appMainTabDeeplinkTestValue + self[AuthClient.self] = .appMainTabDeeplinkTestValue + self[VersionClient.self] = .appMainTabDeeplinkTestValue + self[UserDefaultsClient.self] = .appMainTabDeeplinkTestValue + self[NotificationClient.self] = .appMainTabDeeplinkTestValue + self[PasteboardClient.self] = .noop + self[UserNotificationClient.self] = .noop + self[RemoteNotificationsClient.self] = .noop + self[DeeplinkRouteClient.self] = deeplinkRouteClient + } +} + +extension CategoryClient { + static let appMainTabDeeplinkTestValue: Self = .init( + 카테고리_삭제: { _ in }, + 카테고리_수정: { categoryId, _ in + MainTabDeeplinkTestFixtures.categoryDetailResponse(categoryId: categoryId) + }, + 카테고리_목록_조회: { _, _, _ in + let delay = await MainTabDeeplinkRouteOrder.shared.nextDelayNanoseconds() + try? await Task.sleep(nanoseconds: delay) + return MainTabDeeplinkTestFixtures.categoryListResponse + }, + 카테고리_생성: { _ in + MainTabDeeplinkTestFixtures.categoryDetailResponse(categoryId: 2) + }, + 카테고리_프로필_목록_조회: { + MainTabDeeplinkTestFixtures.categoryImageResponses + }, + 유저_카테고리_개수_조회: { + MainTabDeeplinkTestFixtures.categoryCountResponse + }, + 카테고리_상세_조회: { categoryId in + MainTabDeeplinkTestFixtures.categoryDetailResponse( + categoryId: Int(categoryId) ?? 2 + ) + }, + 공유받은_카테고리_조회: { categoryId, _ in + MainTabDeeplinkTestFixtures.sharedCategoryResponse( + categoryId: Int(categoryId) ?? 2 + ) + }, + 공유받은_카테고리_저장: { _ in }, + 포킷_초대된_유저_목록_조회: { categoryId in + MainTabDeeplinkTestFixtures.invitedUserResponses(categoryId: categoryId) + }, + 포킷_내보내기: { _, _ in }, + 포킷_나가기: { _ in }, + 포킷_초대_수락: { _ in } + ) +} + +extension ContentClient { + static let appMainTabDeeplinkTestValue: Self = .init( + 컨텐츠_삭제: { _ in }, + 컨텐츠_상세_조회: { contentId in + MainTabDeeplinkTestFixtures.contentDetailResponse( + contentId: Int(contentId) ?? 777 + ) + }, + 컨텐츠_수정: { contentId, _ in + MainTabDeeplinkTestFixtures.contentDetailResponse( + contentId: Int(contentId) ?? 777 + ) + }, + 컨텐츠_추가: { _ in + MainTabDeeplinkTestFixtures.contentDetailResponse(contentId: 777) + }, + 즐겨찾기: { contentId in + MainTabDeeplinkTestFixtures.bookmarkResponse( + contentId: Int(contentId) ?? 777 + ) + }, + 즐겨찾기_취소: { _ in }, + 카테고리_내_컨텐츠_목록_조회: { categoryId, _, _ in + MainTabDeeplinkTestFixtures.contentListResponse( + categoryId: Int(categoryId) ?? 2 + ) + }, + 미분류_카테고리_컨텐츠_조회: { _ in + MainTabDeeplinkTestFixtures.emptyContentListResponse + }, + 컨텐츠_검색: { _, _ in + MainTabDeeplinkTestFixtures.contentListResponse(categoryId: 2) + }, + 썸네일_수정: { _, _ in }, + 미분류_링크_포킷_이동: { _ in }, + 미분류_링크_삭제: { _ in }, + 추천_컨텐츠_조회: { _, _ in + MainTabDeeplinkTestFixtures.contentListResponse(categoryId: 2) + }, + 컨텐츠_신고사유_조회: { [ReportReasonResponse.mock] }, + 컨텐츠_신고: { _ in }, + 컨텐츠_신고_사유: { _, _ in } + ) +} + +extension NotificationClient { + static let appMainTabDeeplinkTestValue: Self = .init( + 알림_목록_조회: { _ in MainTabDeeplinkTestFixtures.notificationListResponse }, + 알림_읽음: { _ in }, + 알림_삭제: { _ in } + ) +} + +extension UserClient { + static let appMainTabDeeplinkTestValue: Self = .init( + 프로필_수정: { _ in MainTabDeeplinkTestFixtures.baseUserResponse }, + 닉네임_수정: { _ in MainTabDeeplinkTestFixtures.baseUserResponse }, + 회원등록: { _ in MainTabDeeplinkTestFixtures.baseUserResponse }, + 닉네임_중복_체크: { _ in MainTabDeeplinkTestFixtures.nicknameCheckResponse }, + 관심사_목록_조회: { MainTabDeeplinkTestFixtures.interests }, + 닉네임_조회: { MainTabDeeplinkTestFixtures.baseUserResponse }, + fcm_토큰_저장: { _ in MainTabDeeplinkTestFixtures.fcmResponse }, + 프로필_이미지_목록_조회: { MainTabDeeplinkTestFixtures.profileImages }, + 유저_관심사_목록_조회: { MainTabDeeplinkTestFixtures.interests }, + 관심사_수정: { _ in } + ) +} + +extension AuthClient { + static let appMainTabDeeplinkTestValue: Self = .init( + 로그인: { _ in MainTabDeeplinkTestFixtures.tokenResponse }, + 회원탈퇴: { _ in }, + 토큰재발급: { _ in ReissueResponse(accessToken: "uitest-access-token") }, + apple: { _ in MainTabDeeplinkTestFixtures.appleTokenResponse }, + appleRevoke: { _, _ in } + ) +} + +extension VersionClient { + static let appMainTabDeeplinkTestValue: Self = .init( + 버전체크: { MainTabDeeplinkTestFixtures.versionResponse } + ) +} + +extension UserDefaultsClient { + static let appMainTabDeeplinkTestValue: Self = .init( + boolKey: { _ in false }, + stringKey: { _ in nil }, + stringArrayKey: { _ in nil }, + removeBool: { _ in }, + removeString: { _ in }, + removeStringArray: { _ in }, + setBool: { _, _ in }, + setString: { _, _ in }, + setStringArray: { _, _ in } + ) +} + +private actor MainTabDeeplinkRouteOrder { + static let shared = MainTabDeeplinkRouteOrder() + + private var requestCount = 0 + + func nextDelayNanoseconds() -> UInt64 { + requestCount += 1 + return UInt64(requestCount) * 30_000_000 + } +} + +private enum MainTabDeeplinkTestFixtures { + private static let category2ID = 2 + private static let category3ID = 3 + private static let category2Name = "UITest-Category-2" + private static let category3Name = "UITest-Category-3" + + static let interests: [InterestResponse] = [ + .init(code: "it", description: "IT"), + .init(code: "design", description: "디자인") + ] + + static let categoryListResponse: CategoryListInquiryResponse = decode( + [ + "data": [ + categoryItem( + id: category2ID, + name: category2Name, + contentCount: 2, + userCount: 2 + ), + categoryItem( + id: category3ID, + name: category3Name, + contentCount: 1, + userCount: 2 + ) + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + + static let categoryImageResponses: [CategoryImageResponse] = decode( + [ + ["imageId": 2002, "imageUrl": "https://example.com/category-2.png"], + ["imageId": 2003, "imageUrl": "https://example.com/category-3.png"] + ] + ) + + static let categoryCountResponse: CategoryCountResponse = decode( + ["categoryTotalCount": 2] + ) + + static let baseUserResponse: BaseUserResponse = decode( + [ + "id": 100, + "email": "uitest@pokit.app", + "nickname": "UITestUser", + "profileImage": ["id": 10, "url": "https://example.com/profile.png"] + ] + ) + + static let nicknameCheckResponse: NicknameCheckResponse = decode( + ["isDuplicate": false] + ) + + static let profileImages: [BaseProfileImageResponse] = decode( + [ + ["id": 10, "url": "https://example.com/profile.png"] + ] + ) + + static let fcmResponse: FCMResponse = decode( + ["userId": 100, "token": "uitest-fcm-token"] + ) + + static let tokenResponse: TokenResponse = decode( + [ + "accessToken": "uitest-access-token", + "refreshToken": "uitest-refresh-token", + "isRegistered": true + ] + ) + + static let appleTokenResponse: AppleTokenResponse = decode( + ["refresh_token": "uitest-apple-refresh-token"] + ) + + static let versionResponse: VersionResponse = decode( + [ + "results": [ + ["version": "1.0.0", "trackId": 2415354644] + ] + ] + ) + + static let notificationListResponse: NotificationListInquiryResponse = decode( + [ + "data": [ + [ + "id": 1, + "notificationType": "LINK_ADDED", + "title": "'뜨개질' 포킷에 링크가 추가되었어요", + "body": "OO님이 추가한 링크를 지금 확인해보세요", + "categoryImageUrl": "https://example.com/category-2.png", + "isRead": false, + "navigationType": "CONTENT_DETAIL", + "deepLink": "pokit://shared?categoryId=2&contentId=777", + "createdAt": "2026-02-18T00:00:00Z" + ] + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + + static let emptyContentListResponse: ContentListInquiryResponse = decode( + [ + "data": [], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + + static func categoryDetailResponse(categoryId: Int) -> CategoryEditResponse { + decode( + [ + "categoryId": categoryId, + "categoryName": categoryName(for: categoryId), + "categoryImage": [ + "imageId": 2000 + categoryId, + "imageUrl": "https://example.com/category-\(categoryId).png" + ], + "alertEnabled": true + ] + ) + } + + static func sharedCategoryResponse(categoryId: Int) -> SharedCategoryResponse { + decode( + [ + "category": [ + "categoryId": categoryId, + "categoryName": categoryName(for: categoryId), + "contentCount": 1, + "categoryImageId": 2000 + categoryId, + "categoryImageUrl": "https://example.com/category-\(categoryId).png" + ], + "contents": [ + "data": [ + [ + "contentId": 777, + "data": "https://example.com/777", + "domain": "example.com", + "title": "UITest-Content-777", + "memo": "uitest memo", + "thumbNail": "https://example.com/thumb-777.png", + "createdAt": "2026-02-18T00:00:00Z" + ] + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ] + ) + } + + static func invitedUserResponses(categoryId: Int) -> [InvitedUserResponse] { + decode( + [ + [ + "userId": 1000 + categoryId, + "nickname": "Owner-\(categoryId)", + "profileImage": [ + "id": 1000 + categoryId, + "url": "https://example.com/owner-\(categoryId).png" + ] + ], + [ + "userId": 2000 + categoryId, + "nickname": "Member-\(categoryId)A", + "profileImage": NSNull() + ] + ] + ) + } + + static func contentListResponse(categoryId: Int) -> ContentListInquiryResponse { + switch categoryId { + case category2ID: + return decode( + [ + "data": [ + contentBase( + contentId: 777, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-777" + ), + contentBase( + contentId: 778, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-778" + ) + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + case category3ID: + return decode( + [ + "data": [ + contentBase( + contentId: 888, + categoryId: category3ID, + categoryName: category3Name, + title: "UITest-Content-888" + ) + ], + "page": 0, + "size": 30, + "sort": [sort()], + "hasNext": false + ] + ) + default: + return emptyContentListResponse + } + } + + static func contentDetailResponse(contentId: Int) -> ContentDetailResponse { + switch contentId { + case 777: + return decode( + contentDetail( + contentId: 777, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-777" + ) + ) + case 778: + return decode( + contentDetail( + contentId: 778, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-778" + ) + ) + case 888: + return decode( + contentDetail( + contentId: 888, + categoryId: category3ID, + categoryName: category3Name, + title: "UITest-Content-888" + ) + ) + default: + return decode( + contentDetail( + contentId: contentId, + categoryId: category2ID, + categoryName: category2Name, + title: "UITest-Content-\(contentId)" + ) + ) + } + } + + static func bookmarkResponse(contentId: Int) -> BookmarkResponse { + decode(["contentId": contentId]) + } + + private static func categoryName(for id: Int) -> String { + switch id { + case category2ID: return category2Name + case category3ID: return category3Name + default: return "UITest-Category-\(id)" + } + } + + private static func sort() -> [String: Any] { + [ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ] + } + + private static func categoryItem( + id: Int, + name: String, + contentCount: Int, + userCount: Int + ) -> [String: Any] { + [ + "categoryId": id, + "userId": 100, + "categoryName": name, + "categoryImage": [ + "imageId": 2000 + id, + "imageUrl": "https://example.com/category-\(id).png" + ], + "contentCount": contentCount, + "createdAt": "2026-02-18T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "default", + "userCount": userCount, + "isFavorite": false, + "alertEnabled": true + ] + } + + private static func contentBase( + contentId: Int, + categoryId: Int, + categoryName: String, + title: String + ) -> [String: Any] { + [ + "contentId": contentId, + "category": [ + "categoryId": categoryId, + "categoryName": categoryName + ], + "data": "https://example.com/\(contentId)", + "domain": "example.com", + "title": title, + "memo": "memo-\(contentId)", + "thumbNail": "https://example.com/thumb-\(contentId).png", + "createdAt": "2026-02-18T00:00:00Z", + "isRead": false, + "isFavorite": false, + "keyword": NSNull(), + "author": [ + "userId": 100, + "nickname": "UITestUser", + "profileImageUrl": "https://example.com/profile.png" + ], + "memoExists": true + ] + } + + private static func contentDetail( + contentId: Int, + categoryId: Int, + categoryName: String, + title: String + ) -> [String: Any] { + [ + "contentId": contentId, + "category": [ + "categoryId": categoryId, + "categoryName": categoryName + ], + "data": "https://example.com/\(contentId)", + "title": title, + "memo": "memo-\(contentId)", + "alertYn": "NO", + "createdAt": "2026-02-18T00:00:00Z", + "favorites": false, + "keyword": "예능", + "userNickname": "UITestUser", + "author": [ + "userId": 100, + "nickname": "UITestUser", + "profileImageUrl": "https://example.com/profile.png" + ] + ] + } + + private static func decode(_ jsonObject: Any) -> T { + do { + let data = try JSONSerialization.data(withJSONObject: jsonObject) + return try JSONDecoder().decode(T.self, from: data) + } catch { + fatalError("UITest fixture decode failed for \(T.self): \(error)") + } + } +} +#endif diff --git a/Projects/CoreKit/CoreKitTests/Sources/CoreKitTests.swift b/Projects/CoreKit/CoreKitTests/Sources/CoreKitTests.swift new file mode 100644 index 00000000..0ee2394f --- /dev/null +++ b/Projects/CoreKit/CoreKitTests/Sources/CoreKitTests.swift @@ -0,0 +1,7 @@ +import Testing + +@MainActor +struct CoreKitTests { + @Test + func smoke() {} +} diff --git a/Projects/CoreKit/CoreKitTests/Sources/DeeplinkRouterTests.swift b/Projects/CoreKit/CoreKitTests/Sources/DeeplinkRouterTests.swift new file mode 100644 index 00000000..6f3d2e20 --- /dev/null +++ b/Projects/CoreKit/CoreKitTests/Sources/DeeplinkRouterTests.swift @@ -0,0 +1,51 @@ +import Foundation +import CoreKit +import Testing + +@MainActor +struct DeeplinkRouterTests { + @Test("DeeplinkRouter 큐잉/FIFO 배출/브로드캐스트/파싱") + func queueDrainBroadcastAndParse() async { + let router = DeeplinkRouteClient.liveValue + + await router.routeTo(URL(string: "pokit://shared?categoryId=1&contentId=2&userId=3")) + await router.routeTo(URL(string: "pokit://alert")) + + var queuedIterator = router.routeStream().makeAsyncIterator() + let queuedFirst = await queuedIterator.next() + let queuedSecond = await queuedIterator.next() + + #expect(queuedFirst == .pokitShared( + categoryId: 1, + contentId: 2, + userId: 3 + )) + #expect(queuedSecond == .pokitAlert) + + var firstSubscriber = router.routeStream().makeAsyncIterator() + var secondSubscriber = router.routeStream().makeAsyncIterator() + + await router.routeTo(URL(string: "pokit://shared?categoryId=9&contentId=10&userId=11")) + + let firstReceived = await firstSubscriber.next() + let secondReceived = await secondSubscriber.next() + + #expect(firstReceived == .pokitShared( + categoryId: 9, + contentId: 10, + userId: 11 + )) + #expect(secondReceived == .pokitShared( + categoryId: 9, + contentId: 10, + userId: 11 + )) + + var invalidCheckSubscriber = router.routeStream().makeAsyncIterator() + await router.routeTo(URL(string: "https://example.com/deeplink")) + await router.routeTo(URL(string: "pokit://alert")) + + let afterInvalid = await invalidCheckSubscriber.next() + #expect(afterInvalid == .pokitAlert) + } +} diff --git a/Projects/CoreKit/Project.swift b/Projects/CoreKit/Project.swift index a42f182a..cdcef3c3 100644 --- a/Projects/CoreKit/Project.swift +++ b/Projects/CoreKit/Project.swift @@ -41,7 +41,17 @@ let coreKit: Target = .target( settings: .settings() ) +let coreKitTests: Target = .makeTarget( + name: "CoreKitTests", + product: .unitTests, + bundleName: "CoreKitTests", + infoPlist: .dictionary(["ENABLE_TESTING_SEARCH_PATHS": "YES"]), + dependencies: [ + .target(coreKit) + ] +) + let project = Project( name: "CoreKit", - targets: [coreKit] + targets: [coreKit, coreKitTests] ) diff --git a/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkActor.swift b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkActor.swift new file mode 100644 index 00000000..7ba7ee96 --- /dev/null +++ b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkActor.swift @@ -0,0 +1,14 @@ +// +// DeeplinkActor.swift +// CoreKit +// +// Created by 김도형 on 2/17/26. +// + +import Foundation + +@globalActor +public struct DeeplinkActor { + public actor ActorType {} + public static let shared = ActorType() +} diff --git a/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRoute.swift b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRoute.swift new file mode 100644 index 00000000..6408c57c --- /dev/null +++ b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRoute.swift @@ -0,0 +1,14 @@ +// +// DeeplinkRoute.swift +// CoreKit +// +// Created by 김도형 on 2/17/26. +// + +import Foundation + +public enum DeeplinkRoute: Equatable, Sendable { + case kakaoSharedCategory(categoryId: Int, shareType: String?) + case pokitShared(categoryId: Int?, contentId: Int?, userId: Int?) + case pokitAlert +} diff --git a/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient+LiveKey.swift b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient+LiveKey.swift new file mode 100644 index 00000000..a455d8a1 --- /dev/null +++ b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient+LiveKey.swift @@ -0,0 +1,110 @@ +// +// DeeplinkRouteClient+LiveKey.swift +// CoreKit +// +// Created by 김도형 on 2/17/26. +// + +import Foundation + +import Dependencies + +extension DeeplinkRouteClient: DependencyKey { + public static let liveValue: Self = { + return Self( + routeTo: { url in + await DeeplinkRouter.shared.routeTo(url: url) + }, + routeStream: { + AsyncStream { continuation in + let id = UUID() + Task { + await DeeplinkRouter.shared.addSubscriber( + id: id, + continuation: continuation + ) + } + + continuation.onTermination = { _ in + Task { + await DeeplinkRouter.shared.removeSubscriber(id: id) + } + } + } + } + ) + }() +} + +@DeeplinkActor +private final class DeeplinkRouter { + static let shared = DeeplinkRouter() + + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var queuedRoutes: [DeeplinkRoute] = [] + + private init() {} + + func routeTo(url: URL?) { + guard + let url, + let route = parse(url: url) + else { return } + + guard !subscribers.isEmpty else { + queuedRoutes.append(route) + return + } + + broadcast(route) + } + + func addSubscriber( + id: UUID, + continuation: AsyncStream.Continuation + ) { + subscribers[id] = continuation + drainQueueIfNeeded() + } + + func removeSubscriber(id: UUID) { + subscribers[id] = nil + } + + private func drainQueueIfNeeded() { + guard !queuedRoutes.isEmpty else { return } + + let routes = queuedRoutes + queuedRoutes.removeAll() + + for route in routes { + broadcast(route) + } + } + + private func broadcast(_ route: DeeplinkRoute) { + subscribers.values.forEach { continuation in + continuation.yield(route) + } + } + + private func parse(url: URL) -> DeeplinkRoute? { + if let route = KakaoDeeplink(url: url) { + switch route { + case let .sharedCategory(categoryId, shareType): + return .kakaoSharedCategory(categoryId: categoryId, shareType: shareType) + } + } + + if let route = PokitDeeplink(url: url) { + switch route { + case let .shared(categoryId, contentId, userId): + return .pokitShared(categoryId: categoryId, contentId: contentId, userId: userId) + case .alert: + return .pokitAlert + } + } + + return nil + } +} diff --git a/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient+TestKey.swift b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient+TestKey.swift new file mode 100644 index 00000000..a0701a7f --- /dev/null +++ b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient+TestKey.swift @@ -0,0 +1,22 @@ +// +// DeeplinkRouteClient+TestKey.swift +// CoreKit +// +// Created by 김도형 on 2/17/26. +// + +import Foundation + +import Dependencies + +extension DeeplinkRouteClient: TestDependencyKey { + public static let previewValue: Self = .noop + public static let testValue: Self = .noop +} + +public extension DeeplinkRouteClient { + static let noop: Self = .init( + routeTo: { _ in }, + routeStream: { .finished } + ) +} diff --git a/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient.swift b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient.swift new file mode 100644 index 00000000..2c269852 --- /dev/null +++ b/Projects/CoreKit/Sources/Core/Deeplink/DeeplinkRouteClient.swift @@ -0,0 +1,16 @@ +// +// DeeplinkRouteClient.swift +// CoreKit +// +// Created by 김도형 on 2/17/26. +// + +import Foundation + +import DependenciesMacros + +@DependencyClient +public struct DeeplinkRouteClient: Sendable { + public var routeTo: @Sendable (URL?) async -> Void = { _ in } + public var routeStream: @Sendable () -> AsyncStream = { .finished } +} diff --git a/Projects/CoreKit/Sources/Core/Deeplink/KakaoDeeplink.swift b/Projects/CoreKit/Sources/Core/Deeplink/KakaoDeeplink.swift new file mode 100644 index 00000000..1617a7ab --- /dev/null +++ b/Projects/CoreKit/Sources/Core/Deeplink/KakaoDeeplink.swift @@ -0,0 +1,26 @@ +// +// KakaoDeeplink.swift +// CoreKit +// +// Created by 김도형 on 2/17/26. +// + +import Foundation + +import SchemeRoute + +@SchemeRoutable +enum KakaoDeeplink: Equatable, Sendable { + static var scheme: String { + guard + let appKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String, + !appKey.isEmpty + else { + return "__invalid_kakao_scheme__" + } + return "kakao\(appKey)" + } + + @SchemePattern("kakaolink?categoryId=${categoryId}&shareType=${shareType}") + case sharedCategory(categoryId: Int, shareType: String?) +} diff --git a/Projects/CoreKit/Sources/Core/Deeplink/PokitDeeplink.swift b/Projects/CoreKit/Sources/Core/Deeplink/PokitDeeplink.swift new file mode 100644 index 00000000..0e05ea48 --- /dev/null +++ b/Projects/CoreKit/Sources/Core/Deeplink/PokitDeeplink.swift @@ -0,0 +1,21 @@ +// +// PokitDeeplink.swift +// CoreKit +// +// Created by 김도형 on 2/17/26. +// + +import Foundation + +import SchemeRoute + +@SchemeRoutable +enum PokitDeeplink: Equatable, Sendable { + static var scheme: String { "pokit" } + + @SchemePattern("shared?categoryId=${categoryId}&contentId=${contentId}&userId=${userId}") + case shared(categoryId: Int?, contentId: Int?, userId: Int?) + + @SchemePattern("alert") + case alert +} diff --git a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+LiveKey.swift index e249da8a..7e72f6fe 100644 --- a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+LiveKey.swift @@ -71,6 +71,7 @@ extension UserNotificationClient { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { + print(response.notification.request.content.userInfo) self.continuation.yield( .didReceiveResponse(.init(rawValue: response)) { completionHandler() } ) diff --git a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift index 64d8e411..9bd9b4ad 100644 --- a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift @@ -22,7 +22,7 @@ public struct UserNotificationClient { @CasePathable public enum DelegateEvent { - case didReceiveResponse(Notification.Response, completionHandler: @Sendable () -> Void) + case didReceiveResponse(Notification.Response, completionHandler: @MainActor @Sendable () -> Void) case openSettingsForNotification(Notification?) case willPresentNotification( Notification, completionHandler: @Sendable (UNNotificationPresentationOptions) -> Void diff --git a/Projects/CoreKit/Sources/Data/DTO/Alert/AlertListInquiryResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Alert/AlertListInquiryResponse.swift index a4a496d3..ea2d955f 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Alert/AlertListInquiryResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Alert/AlertListInquiryResponse.swift @@ -21,6 +21,7 @@ public struct AlertItemInquiryResponse: Decodable { public let id: Int public let userId: Int public let contentId: Int + public let deeplink: String? public let thumbNail: String public let title: String public let body: String @@ -34,6 +35,7 @@ extension AlertListInquiryResponse { id: 999999, userId: 898989898, contentId: 21312, + deeplink: nil, thumbNail: Constants.mockImageUrl, title: "제목타이틀", body: "바디", @@ -54,4 +56,3 @@ extension AlertListInquiryResponse { hasNext: false ) } - diff --git a/Projects/CoreKit/Sources/Data/DTO/Base/BasePageableRequest.swift b/Projects/CoreKit/Sources/Data/DTO/Base/BasePageableRequest.swift index dcf66c39..af45bdb6 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Base/BasePageableRequest.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Base/BasePageableRequest.swift @@ -8,9 +8,9 @@ import Foundation /// Pageable public struct BasePageableRequest: Equatable, Encodable { - let page: Int - let size: Int - let sort: [String] + public let page: Int + public let size: Int + public let sort: [String] public init(page: Int, size: Int, sort: [String]) { self.page = page diff --git a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseRequest.swift b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseRequest.swift index dbf0fd87..b44e9284 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseRequest.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseRequest.swift @@ -8,12 +8,12 @@ import Foundation /// 컨텐츠 상세조회, 컨텐츠 수정, 컨텐츠 추가 API Request public struct ContentBaseRequest: Encodable { - let data: String - let title: String - let categoryId: Int - let memo: String - let alertYn: String - let thumbNail: String? + public let data: String + public let title: String + public let categoryId: Int + public let memo: String + public let alertYn: String + public let thumbNail: String? public init( data: String, diff --git a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift index ea8d594f..5c4ea3a0 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift @@ -6,6 +6,7 @@ // import Foundation +import Util /// 컨텐츠 상세조회, 컨텐츠 수정, 컨텐츠 추가 API Response public struct ContentBaseResponse: Decodable { public let contentId: Int @@ -19,6 +20,85 @@ public struct ContentBaseResponse: Decodable { public let isRead: Bool? public let isFavorite: Bool? public let keyword: String? + public let authorUserId: Int? + public let authorNickname: String? + public let authorProfileImageURL: String? + + private enum CodingKeys: String, CodingKey { + case contentId + case category + case data + case domain + case title + case memo + case thumbNail + case createdAt + case isRead + case isFavorite + case keyword + case author + case authorUserId + case authorNickname + case authorProfileImageURL + } + + private struct Author: Decodable { + let userId: Int? + let nickname: String? + let profileImageUrl: String? + } + + public init( + contentId: Int, + category: Category, + data: String, + domain: String, + title: String, + memo: String?, + thumbNail: String, + createdAt: String, + isRead: Bool?, + isFavorite: Bool?, + keyword: String? = nil, + authorUserId: Int? = nil, + authorNickname: String? = nil, + authorProfileImageURL: String? = nil + ) { + self.contentId = contentId + self.category = category + self.data = data + self.domain = domain + self.title = title + self.memo = memo + self.thumbNail = thumbNail + self.createdAt = createdAt + self.isRead = isRead + self.isFavorite = isFavorite + self.keyword = keyword + self.authorUserId = authorUserId + self.authorNickname = authorNickname + self.authorProfileImageURL = authorProfileImageURL + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let author = try container.decodeIfPresent(Author.self, forKey: .author) + + self.contentId = try container.decode(Int.self, forKey: .contentId) + self.category = try container.decode(Category.self, forKey: .category) + self.data = try container.decode(String.self, forKey: .data) + self.domain = try container.decode(String.self, forKey: .domain) + self.title = try container.decode(String.self, forKey: .title) + self.memo = try container.decodeIfPresent(String.self, forKey: .memo) + self.thumbNail = try container.decode(String.self, forKey: .thumbNail) + self.createdAt = try container.decode(String.self, forKey: .createdAt) + self.isRead = try container.decodeIfPresent(Bool.self, forKey: .isRead) + self.isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) + self.keyword = try container.decodeIfPresent(String.self, forKey: .keyword) + self.authorUserId = try container.decodeIfPresent(Int.self, forKey: .authorUserId) ?? author?.userId + self.authorNickname = try container.decodeIfPresent(String.self, forKey: .authorNickname) ?? author?.nickname + self.authorProfileImageURL = try container.decodeIfPresent(String.self, forKey: .authorProfileImageURL) ?? author?.profileImageUrl + } } extension ContentBaseResponse { @@ -37,7 +117,10 @@ extension ContentBaseResponse { createdAt: "2024.12.03", isRead: false, isFavorite: true, - keyword: "예능" + keyword: "예능", + authorUserId: 1000 + id, + authorNickname: "Author-\(id)", + authorProfileImageURL: Constants.mockImageUrl ) } } diff --git a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift index 182b2b36..6753ac64 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift @@ -12,16 +12,19 @@ public struct CategoryEditRequest: Encodable { public let categoryImageId: Int public let openType: String public let keywordType: String + public let alertEnabled: Bool? public init( categoryName: String, categoryImageId: Int, openType: String, - keywordType: String + keywordType: String, + alertEnabled: Bool? = nil ) { self.categoryName = categoryName self.categoryImageId = categoryImageId self.openType = openType self.keywordType = keywordType + self.alertEnabled = alertEnabled } } diff --git a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditResponse.swift index a1681c90..5cc97079 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditResponse.swift @@ -13,6 +13,7 @@ public struct CategoryEditResponse: Decodable { public let categoryId: Int public let categoryName: String public let categoryImage: CategoryImageResponse + public let alertEnabled: Bool } extension CategoryEditResponse { @@ -22,7 +23,8 @@ extension CategoryEditResponse { categoryImage: CategoryImageResponse( imageId: 4441, imageUrl: Constants.mockImageUrl - ) + ), + alertEnabled: true ) } diff --git a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift index f5a723b7..c61425e1 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift @@ -29,6 +29,33 @@ public struct CategoryItemInquiryResponse: Decodable { public let keywordType: String public let userCount: Int public let isFavorite: Bool + public let alertEnabled: Bool + + public init( + categoryId: Int, + userId: Int, + categoryName: String, + categoryImage: CategoryImageResponse, + contentCount: Int, + createdAt: String, + openType: String, + keywordType: String, + userCount: Int, + isFavorite: Bool, + alertEnabled: Bool = true + ) { + self.categoryId = categoryId + self.userId = userId + self.categoryName = categoryName + self.categoryImage = categoryImage + self.contentCount = contentCount + self.createdAt = createdAt + self.openType = openType + self.keywordType = keywordType + self.userCount = userCount + self.isFavorite = isFavorite + self.alertEnabled = alertEnabled + } } /// Sort public struct ItemInquirySortResponse: Decodable { @@ -53,7 +80,8 @@ public extension CategoryItemInquiryResponse { openType: "PRIVATE", keywordType: "스포츠/레저", userCount: 0, - isFavorite: false + isFavorite: false, + alertEnabled: true ) } @@ -73,7 +101,8 @@ extension CategoryListInquiryResponse { openType: "PRIVATE", keywordType: "스포츠/레저", userCount: 0, - isFavorite: false + isFavorite: false, + alertEnabled: true ), CategoryItemInquiryResponse( categoryId: 2, @@ -88,7 +117,8 @@ extension CategoryListInquiryResponse { openType: "PUBLIC", keywordType: "스포츠/레저", userCount: 1, - isFavorite: false + isFavorite: false, + alertEnabled: true ), CategoryItemInquiryResponse( categoryId: 3, @@ -103,7 +133,8 @@ extension CategoryListInquiryResponse { openType: "PUBLIC", keywordType: "스포츠/레저", userCount: 5, - isFavorite: false + isFavorite: false, + alertEnabled: true ) ], page: 1, diff --git a/Projects/CoreKit/Sources/Data/DTO/Category/SharedCategoryResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Category/SharedCategoryResponse.swift index 6cdd503a..1bd65699 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Category/SharedCategoryResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Category/SharedCategoryResponse.swift @@ -52,6 +52,68 @@ extension SharedCategoryResponse { public let memo: String public let thumbNail: String public let createdAt: String + public let authorUserId: Int? + public let authorNickname: String? + public let authorProfileImageURL: String? + + private enum CodingKeys: String, CodingKey { + case contentId + case data + case domain + case title + case memo + case thumbNail + case createdAt + case author + case authorUserId + case authorNickname + case authorProfileImageURL + } + + private struct Author: Decodable { + let userId: Int? + let nickname: String? + let profileImageUrl: String? + } + + public init( + contentId: Int, + data: String, + domain: String, + title: String, + memo: String, + thumbNail: String, + createdAt: String, + authorUserId: Int? = nil, + authorNickname: String? = nil, + authorProfileImageURL: String? = nil + ) { + self.contentId = contentId + self.data = data + self.domain = domain + self.title = title + self.memo = memo + self.thumbNail = thumbNail + self.createdAt = createdAt + self.authorUserId = authorUserId + self.authorNickname = authorNickname + self.authorProfileImageURL = authorProfileImageURL + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let author = try container.decodeIfPresent(Author.self, forKey: .author) + self.contentId = try container.decode(Int.self, forKey: .contentId) + self.data = try container.decode(String.self, forKey: .data) + self.domain = try container.decode(String.self, forKey: .domain) + self.title = try container.decode(String.self, forKey: .title) + self.memo = try container.decode(String.self, forKey: .memo) + self.thumbNail = try container.decode(String.self, forKey: .thumbNail) + self.createdAt = try container.decode(String.self, forKey: .createdAt) + self.authorUserId = try container.decodeIfPresent(Int.self, forKey: .authorUserId) ?? author?.userId + self.authorNickname = try container.decodeIfPresent(String.self, forKey: .authorNickname) ?? author?.nickname + self.authorProfileImageURL = try container.decodeIfPresent(String.self, forKey: .authorProfileImageURL) ?? author?.profileImageUrl + } } } @@ -64,7 +126,10 @@ extension SharedCategoryResponse.Content { title: "신서유기", memo: "신서유기는 재밌어", thumbNail: "https://i.ytimg.com/vi/NnOC4_kH0ok/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDN6u6mTjbaVmRZ4biJS_aDq4uvAQ", - createdAt: "2024.08.08" + createdAt: "2024.08.08", + authorUserId: 100, + authorNickname: "PokitMons", + authorProfileImageURL: Constants.mockImageUrl ) } } diff --git a/Projects/CoreKit/Sources/Data/DTO/Content/ContentDetailResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Content/ContentDetailResponse.swift index f3fc70a6..1d448764 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Content/ContentDetailResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Content/ContentDetailResponse.swift @@ -6,6 +6,7 @@ // import Foundation +import Util public struct ContentDetailResponse: Decodable { public let contentId: Int @@ -16,6 +17,83 @@ public struct ContentDetailResponse: Decodable { public let alertYn: String public let createdAt: String public let favorites: Bool + public let keyword: String? + public let userNickname: String? + public let authorUserId: Int? + public let authorNickname: String? + public let authorProfileImageURL: String? + + private enum CodingKeys: String, CodingKey { + case contentId + case category + case data + case title + case memo + case alertYn + case createdAt + case favorites + case keyword + case userNickname + case author + case authorUserId + case authorNickname + case authorProfileImageURL + } + + private struct Author: Decodable { + let userId: Int? + let nickname: String? + let profileImageUrl: String? + } + + public init( + contentId: Int, + category: BaseCategoryResponse, + data: String, + title: String, + memo: String, + alertYn: String, + createdAt: String, + favorites: Bool, + keyword: String? = nil, + userNickname: String? = nil, + authorUserId: Int? = nil, + authorNickname: String? = nil, + authorProfileImageURL: String? = nil + ) { + self.contentId = contentId + self.category = category + self.data = data + self.title = title + self.memo = memo + self.alertYn = alertYn + self.createdAt = createdAt + self.favorites = favorites + self.keyword = keyword + self.userNickname = userNickname + self.authorUserId = authorUserId + self.authorNickname = authorNickname + self.authorProfileImageURL = authorProfileImageURL + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let author = try container.decodeIfPresent(Author.self, forKey: .author) + + self.contentId = try container.decode(Int.self, forKey: .contentId) + self.category = try container.decode(BaseCategoryResponse.self, forKey: .category) + self.data = try container.decode(String.self, forKey: .data) + self.title = try container.decode(String.self, forKey: .title) + self.memo = try container.decode(String.self, forKey: .memo) + self.alertYn = try container.decode(String.self, forKey: .alertYn) + self.createdAt = try container.decode(String.self, forKey: .createdAt) + self.favorites = try container.decode(Bool.self, forKey: .favorites) + self.keyword = try container.decodeIfPresent(String.self, forKey: .keyword) + self.userNickname = try container.decodeIfPresent(String.self, forKey: .userNickname) + self.authorUserId = try container.decodeIfPresent(Int.self, forKey: .authorUserId) ?? author?.userId + self.authorNickname = try container.decodeIfPresent(String.self, forKey: .authorNickname) ?? author?.nickname + self.authorProfileImageURL = try container.decodeIfPresent(String.self, forKey: .authorProfileImageURL) ?? author?.profileImageUrl + } } extension ContentDetailResponse { @@ -30,6 +108,11 @@ extension ContentDetailResponse { memo: "#티전드 #신서유기5 #신서유기7 #tvN\n회차정보 : 신서유기5 3회, 신서유기7 1회, 신서유기7 2회, 신서유기7 6회\n\n이제는 전설이 되어버린 역대급 장면들..\n묻지도 따지지도 않고 N회차 재생 가봅시다.", alertYn: "YES", createdAt: "2024-07-31T10:10:23.902Z", - favorites: true + favorites: true, + keyword: "예능", + userNickname: "PokitMons", + authorUserId: 100, + authorNickname: "PokitMons", + authorProfileImageURL: Constants.mockImageUrl ) } diff --git a/Projects/CoreKit/Sources/Data/DTO/Content/ContentMoveRequest.swift b/Projects/CoreKit/Sources/Data/DTO/Content/ContentMoveRequest.swift index 3bddab98..450761fa 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Content/ContentMoveRequest.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Content/ContentMoveRequest.swift @@ -7,8 +7,8 @@ import Foundation /// 미분류 링크를 카테고리로 이동 public struct ContentMoveRequest: Encodable { - let contentIds: [Int] - let categoryId: Int + public let contentIds: [Int] + public let categoryId: Int public init(contentIds: [Int], categoryId: Int) { self.contentIds = contentIds diff --git a/Projects/CoreKit/Sources/Data/DTO/Content/ContentReportRequest.swift b/Projects/CoreKit/Sources/Data/DTO/Content/ContentReportRequest.swift new file mode 100644 index 00000000..8d709d9a --- /dev/null +++ b/Projects/CoreKit/Sources/Data/DTO/Content/ContentReportRequest.swift @@ -0,0 +1,16 @@ +// +// ContentReportRequest.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +public struct ContentReportRequest: Encodable { + public let reportReason: String + + public init(reportReason: String) { + self.reportReason = reportReason + } +} diff --git a/Projects/CoreKit/Sources/Data/DTO/Content/ReportReasonResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Content/ReportReasonResponse.swift new file mode 100644 index 00000000..be574a60 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/DTO/Content/ReportReasonResponse.swift @@ -0,0 +1,17 @@ +// +// ReportReasonResponse.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +public struct ReportReasonResponse: Decodable { + public let code: String + public let description: String +} + +extension ReportReasonResponse { + public static let mock: Self = .init(code: "SPAM", description: "스팸") +} diff --git a/Projects/CoreKit/Sources/Data/DTO/Notification/NotificationListInquiryResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Notification/NotificationListInquiryResponse.swift new file mode 100644 index 00000000..1010fa8c --- /dev/null +++ b/Projects/CoreKit/Sources/Data/DTO/Notification/NotificationListInquiryResponse.swift @@ -0,0 +1,37 @@ +// +// NotificationListInquiryResponse.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +public struct NotificationListInquiryResponse: Decodable { + public let data: [NotificationResponse] + public let page: Int + public let size: Int + public let sort: [ItemInquirySortResponse] + public let hasNext: Bool +} + +extension NotificationListInquiryResponse { + public static var mock: Self = .init( + data: [ + .mock(id: 1), + .mock(id: 2) + ], + page: 0, + size: 10, + sort: [ + ItemInquirySortResponse( + direction: "DESC", + nullHandling: "NATIVE", + ascending: false, + property: "createdAt", + ignoreCase: false + ) + ], + hasNext: false + ) +} diff --git a/Projects/CoreKit/Sources/Data/DTO/Notification/NotificationResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Notification/NotificationResponse.swift new file mode 100644 index 00000000..7daead34 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/DTO/Notification/NotificationResponse.swift @@ -0,0 +1,38 @@ +// +// NotificationResponse.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +import Util + +public struct NotificationResponse: Decodable { + public let id: Int + public let notificationType: String + public let title: String + public let body: String + public let categoryImageUrl: String? + public let isRead: Bool + public let navigationType: String + public let deepLink: String? + public let createdAt: String +} + +extension NotificationResponse { + public static func mock(id: Int) -> Self { + .init( + id: id, + notificationType: "LINK_ADDED", + title: "'뜨개질' 포킷에 링크가 추가되었어요", + body: "OO님이 추가한 링크를 지금 확인해보세요", + categoryImageUrl: Constants.mockImageUrl, + isRead: false, + navigationType: "CONTENT_DETAIL", + deepLink: "pokit://shared?categoryId=42&contentId=123", + createdAt: "2026-02-18T00:00:00Z" + ) + } +} diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift index 27db10d0..eb6546da 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift @@ -65,8 +65,14 @@ extension ContentClient: DependencyKey { 추천_컨텐츠_조회: { pageable, keyword in try await provider.request(.추천_컨텐츠_조회(pageable: pageable, keyword: keyword)) }, + 컨텐츠_신고사유_조회: { + try await provider.request(.컨텐츠_신고사유_조회) + }, 컨텐츠_신고: { id in try await provider.requestNoBody(.컨텐츠_신고(contentId: id)) + }, + 컨텐츠_신고_사유: { id, model in + try await provider.requestNoBody(.컨텐츠_신고_사유(contentId: id, model: model)) } ) }() diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift index 25a4a976..8a15365f 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift @@ -22,7 +22,9 @@ extension ContentClient: TestDependencyKey { 미분류_링크_포킷_이동: { _ in }, 미분류_링크_삭제: { _ in }, 추천_컨텐츠_조회: { _, _ in .mock }, - 컨텐츠_신고: { _ in } + 컨텐츠_신고사유_조회: { [ReportReasonResponse.mock] }, + 컨텐츠_신고: { _ in }, + 컨텐츠_신고_사유: { _, _ in } ) }() } diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift index 211468b3..9dfab42c 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift @@ -54,8 +54,12 @@ public struct ContentClient { _ pageable: BasePageableRequest, _ keyword: String? ) async throws -> ContentListInquiryResponse + public var 컨텐츠_신고사유_조회: @Sendable () async throws -> [ReportReasonResponse] public var 컨텐츠_신고: @Sendable ( _ contentId: Int ) async throws -> Void + public var 컨텐츠_신고_사유: @Sendable ( + _ contentId: Int, + _ model: ContentReportRequest + ) async throws -> Void } - diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift index a0475b88..1e173c7a 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift @@ -34,7 +34,9 @@ public enum ContentEndpoint { pageable: BasePageableRequest, keyword: String? ) + case 컨텐츠_신고사유_조회 case 컨텐츠_신고(contentId: Int) + case 컨텐츠_신고_사유(contentId: Int, model: ContentReportRequest) } extension ContentEndpoint: TargetType { @@ -70,8 +72,12 @@ extension ContentEndpoint: TargetType { return "/uncategorized" case .추천_컨텐츠_조회: return "/recommended" + case .컨텐츠_신고사유_조회: + return "/report/reasons" case let .컨텐츠_신고(contentId): - return "report/\(contentId)" + return "/report/\(contentId)" + case let .컨텐츠_신고_사유(contentId, _): + return "/report/\(contentId)" } } @@ -79,7 +85,7 @@ extension ContentEndpoint: TargetType { switch self { case .컨텐츠_삭제, .즐겨찾기_취소, - .미분류_링크_삭제: + .미분류_링크_삭제: return .put case .컨텐츠_상세_조회, @@ -92,11 +98,15 @@ extension ContentEndpoint: TargetType { .썸네일_수정, .미분류_링크_포킷_이동: return .patch + + case .컨텐츠_신고_사유: + return .post case .카태고리_내_컨텐츠_목록_조회, .미분류_카테고리_컨텐츠_조회, .컨텐츠_검색, - .추천_컨텐츠_조회: + .추천_컨텐츠_조회, + .컨텐츠_신고사유_조회: return .get } } @@ -175,6 +185,10 @@ extension ContentEndpoint: TargetType { return .requestJSONEncodable(model) case .컨텐츠_신고: return .requestPlain + case let .컨텐츠_신고_사유(_, model): + return .requestJSONEncodable(model) + case .컨텐츠_신고사유_조회: + return .requestPlain } } diff --git a/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient+LiveKey.swift new file mode 100644 index 00000000..5c558d13 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient+LiveKey.swift @@ -0,0 +1,27 @@ +// +// NotificationClient+LiveKey.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import Dependencies +import Moya + +extension NotificationClient: DependencyKey { + public static let liveValue: Self = { + let provider = MoyaProvider.build() + + return Self( + 알림_목록_조회: { pageable in + try await provider.request(.알림_목록_조회(model: pageable)) + }, + 알림_읽음: { notificationId in + try await provider.requestNoBody(.알림_읽음(notificationId: notificationId)) + }, + 알림_삭제: { notificationId in + try await provider.requestNoBody(.알림_삭제(notificationId: notificationId)) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient+TestKey.swift new file mode 100644 index 00000000..eccbf3ed --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient+TestKey.swift @@ -0,0 +1,18 @@ +// +// NotificationClient+TestKey.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import Dependencies + +extension NotificationClient: TestDependencyKey { + public static let previewValue: Self = { + Self( + 알림_목록_조회: { _ in .mock }, + 알림_읽음: { _ in }, + 알림_삭제: { _ in } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient.swift b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient.swift new file mode 100644 index 00000000..dd7574ea --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationClient.swift @@ -0,0 +1,21 @@ +// +// NotificationClient.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import DependenciesMacros + +@DependencyClient +public struct NotificationClient { + public var 알림_목록_조회: @Sendable ( + _ pageable: BasePageableRequest + ) async throws -> NotificationListInquiryResponse + public var 알림_읽음: @Sendable ( + _ notificationId: Int + ) async throws -> Void + public var 알림_삭제: @Sendable ( + _ notificationId: Int + ) async throws -> Void +} diff --git a/Projects/CoreKit/Sources/Data/Network/Notification/NotificationEndpoint.swift b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationEndpoint.swift new file mode 100644 index 00000000..c7357a9c --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Notification/NotificationEndpoint.swift @@ -0,0 +1,66 @@ +// +// NotificationEndpoint.swift +// CoreKit +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +import Moya +import Util + +public enum NotificationEndpoint { + case 알림_목록_조회(model: BasePageableRequest) + case 알림_읽음(notificationId: Int) + case 알림_삭제(notificationId: Int) +} + +extension NotificationEndpoint: TargetType { + public var baseURL: URL { + Constants.serverURL.appendingPathComponent(Constants.notificationPath, conformingTo: .url) + } + + public var path: String { + switch self { + case .알림_목록_조회: + return "" + case let .알림_읽음(notificationId): + return "/\(notificationId)/read" + case let .알림_삭제(notificationId): + return "/\(notificationId)" + } + } + + public var method: Moya.Method { + switch self { + case .알림_목록_조회: + return .get + case .알림_읽음: + return .patch + case .알림_삭제: + return .delete + } + } + + public var task: Moya.Task { + switch self { + case let .알림_목록_조회(model): + return .requestParameters( + parameters: [ + "page": model.page, + "size": model.size, + "sort": model.sort.map { String($0) }.joined(separator: ",") + ], + encoding: URLEncoding.default + ) + case .알림_읽음, + .알림_삭제: + return .requestPlain + } + } + + public var headers: [String: String]? { + ["Content-Type": "application/json"] + } +} diff --git a/Projects/DSKit/Sources/Components/PokitLinkCard.swift b/Projects/DSKit/Sources/Components/PokitLinkCard.swift index a6bbf4b7..04040ee9 100644 --- a/Projects/DSKit/Sources/Components/PokitLinkCard.swift +++ b/Projects/DSKit/Sources/Components/PokitLinkCard.swift @@ -154,6 +154,21 @@ public struct PokitLinkCard: View { if let memo = link.memo, !memo.isEmpty { PokitBadge(state: .memo) } + + if let authorProfileImageURL = link.authorProfileImageURL, + let url = URL(string: authorProfileImageURL) { + LazyImage(url: url) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Color.pokit(.bg(.disable)) + } + } + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } } } diff --git a/Projects/DSKit/Sources/Components/PokitRadio.swift b/Projects/DSKit/Sources/Components/PokitRadio.swift new file mode 100644 index 00000000..c8f16ba9 --- /dev/null +++ b/Projects/DSKit/Sources/Components/PokitRadio.swift @@ -0,0 +1,81 @@ +// +// PokitRadio.swift +// DSKit +// +// Created by Codex on 4/6/26. +// + +import SwiftUI + +public struct PokitRadio: View { + private let state: RadioState + + public init(state: RadioState) { + self.state = state + } + + public var body: some View { + Circle() + .fill(state.backgroundColor) + .overlay { + Circle() + .stroke(state.borderColor, lineWidth: 2) + } + .overlay { + Circle() + .fill(state.innerColor) + .frame(width: 14, height: 14) + } + .frame(width: 24, height: 24) + .animation(.pokitDissolve, value: state) + } +} + +public extension PokitRadio { + enum RadioState: Equatable { + case `default` + case active + case disable + + var backgroundColor: Color { + switch self { + case .default, .active: + return .pokit(.bg(.base)) + case .disable: + return .pokit(.bg(.disable)) + } + } + + var borderColor: Color { + switch self { + case .default: + return .pokit(.border(.tertiary)) + case .active: + return .pokit(.border(.brand)) + case .disable: + return .pokit(.border(.disable)) + } + } + + var innerColor: Color { + switch self { + case .default: + return .pokit(.icon(.tertiary)) + case .active: + return .pokit(.bg(.brand)) + case .disable: + return .pokit(.icon(.secondary)) + } + } + } +} + +#Preview { + HStack(spacing: 12) { + PokitRadio(state: .default) + PokitRadio(state: .active) + PokitRadio(state: .disable) + } + .padding() + .background(.pokit(.bg(.base))) +} diff --git a/Projects/DSKit/Sources/Components/PokitReportBottomSheet.swift b/Projects/DSKit/Sources/Components/PokitReportBottomSheet.swift new file mode 100644 index 00000000..b82e95f1 --- /dev/null +++ b/Projects/DSKit/Sources/Components/PokitReportBottomSheet.swift @@ -0,0 +1,172 @@ +// +// PokitReportBottomSheet.swift +// DSKit +// +// Created by Codex on 4/6/26. +// + +import SwiftUI + +public struct PokitReportBottomSheet: View { + @Environment(\.dismiss) + private var dismiss + + @State + private var height: CGFloat = 0 + @State + private var selectedReasonID: String? + + private let title: String? + private let message: String? + private let reasons: [Item] + private let onConfirm: (Item) -> Void + + public init( + title: String? = nil, + message: String? = nil, + reasons: [Item], + initialSelectedReasonID: String? = nil, + onConfirm: @escaping (Item) -> Void + ) { + self._selectedReasonID = State(initialValue: initialSelectedReasonID) + self.title = title + self.message = message + self.reasons = reasons + self.onConfirm = onConfirm + } + + public var body: some View { + GeometryReader { proxy in + let bottomSafeArea = proxy.safeAreaInsets.bottom + let topSafeArea = proxy.safeAreaInsets.top + + VStack(spacing: 0) { + if title != nil || message != nil { + header + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 12) + } + + reasonList + + actionButtons + } + .padding(.top, 12 - topSafeArea) + .padding(.bottom, 36 - bottomSafeArea) + .background(.pokit(.bg(.base))) + .pokitPresentationCornerRadius() + .pokitPresentationBackground() + .presentationDragIndicator(.visible) + .readHeight() + .onPreferenceChange(HeightPreferenceKey.self) { height in + if let height { + self.height = height + } + } + .presentationDetents([.height(height)]) + .ignoresSafeArea(edges: .bottom) + } + } +} + +private extension PokitReportBottomSheet { + @ViewBuilder + var header: some View { + VStack(spacing: 8) { + if let title { + Text(title) + .pokitFont(.title2) + .foregroundStyle(.pokit(.text(.primary))) + .multilineTextAlignment(.center) + } + + if let message { + Text(message) + .pokitFont(.b2(.m)) + .foregroundStyle(.pokit(.text(.secondary))) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + var reasonList: some View { + VStack(spacing: 0) { + ForEach(reasons) { reason in + Button { + selectedReasonID = reason.id + } label: { + HStack(spacing: 12) { + PokitRadio( + state: selectedReasonID == reason.id + ? .active + : .default + ) + + Text(reason.title) + .pokitFont(.b1(.m)) + .foregroundStyle(.pokit(.text(.primary))) + .multilineTextAlignment(.leading) + + Spacer(minLength: 0) + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + } + .buttonStyle(.plain) + + if reason != reasons.last { + Rectangle() + .fill(.pokit(.border(.tertiary))) + .frame(height: 1) + } + } + } + .background(.pokit(.bg(.base))) + } + + var actionButtons: some View { + HStack(spacing: 8) { + PokitBottomButton( + "취소", + state: .default(.primary), + action: cancel + ) + + PokitBottomButton( + "신고", + state: selectedReasonID == nil ? .disable : .filled(.primary), + action: confirm + ) + } + .padding(.horizontal, 20) + .padding(.top, 16) + .background(.pokit(.bg(.base))) + } + + func cancel() { + dismiss() + } + + func confirm() { + guard + let selectedReasonID, + let selectedReason = reasons.first(where: { $0.id == selectedReasonID }) + else { return } + onConfirm(selectedReason) + dismiss() + } +} + +public extension PokitReportBottomSheet { + struct Item: Identifiable, Equatable { + public let id: String + public let title: String + + public init(id: String, title: String) { + self.id = id + self.title = title + } + } +} diff --git a/Projects/Domain/Sources/Alert/AlertItem.swift b/Projects/Domain/Sources/Alert/AlertItem.swift index cd66b1e7..e955443f 100644 --- a/Projects/Domain/Sources/Alert/AlertItem.swift +++ b/Projects/Domain/Sources/Alert/AlertItem.swift @@ -11,6 +11,7 @@ public struct AlertItem: Identifiable, Equatable { public let id: Int public let userId: Int public let contentId: Int + public let deeplink: String? public var thumbNail: String public var title: String public var body: String @@ -20,6 +21,7 @@ public struct AlertItem: Identifiable, Equatable { id: Int, userId: Int, contentId: Int, + deeplink: String? = nil, thumbNail: String, title: String, body: String, @@ -28,6 +30,7 @@ public struct AlertItem: Identifiable, Equatable { self.id = id self.userId = userId self.contentId = contentId + self.deeplink = deeplink self.thumbNail = thumbNail self.title = title self.body = body diff --git a/Projects/Domain/Sources/Base/BaseCategory.swift b/Projects/Domain/Sources/Base/BaseCategory.swift index 685efa43..dad38068 100644 --- a/Projects/Domain/Sources/Base/BaseCategory.swift +++ b/Projects/Domain/Sources/Base/BaseCategory.swift @@ -13,14 +13,17 @@ public struct BaseCategory: Equatable { public let categoryId: Int public let categoryName: String public let categoryImage: BaseCategoryImage + public let alertEnabled: Bool public init( categoryId: Int, categoryName: String, - categoryImage: BaseCategoryImage + categoryImage: BaseCategoryImage, + alertEnabled: Bool = true ) { self.categoryId = categoryId self.categoryName = categoryName self.categoryImage = categoryImage + self.alertEnabled = alertEnabled } } diff --git a/Projects/Domain/Sources/Base/BaseCategoryItem.swift b/Projects/Domain/Sources/Base/BaseCategoryItem.swift index dd97a483..1331e70e 100644 --- a/Projects/Domain/Sources/Base/BaseCategoryItem.swift +++ b/Projects/Domain/Sources/Base/BaseCategoryItem.swift @@ -20,6 +20,7 @@ public struct BaseCategoryItem: Identifiable, Equatable, PokitSelectItem, PokitC public let keywordType: BaseInterestType public let userCount: Int public let isFavorite: Bool + public let alertEnabled: Bool public init( id: Int, @@ -31,7 +32,8 @@ public struct BaseCategoryItem: Identifiable, Equatable, PokitSelectItem, PokitC openType: BaseOpenType, keywordType: BaseInterestType, userCount: Int, - isFavorite: Bool + isFavorite: Bool, + alertEnabled: Bool = true ) { self.id = id self.userId = userId @@ -43,5 +45,6 @@ public struct BaseCategoryItem: Identifiable, Equatable, PokitSelectItem, PokitC self.keywordType = keywordType self.userCount = userCount self.isFavorite = isFavorite + self.alertEnabled = alertEnabled } } diff --git a/Projects/Domain/Sources/Base/BaseContentDetail.swift b/Projects/Domain/Sources/Base/BaseContentDetail.swift index 8e8fef34..32f218f7 100644 --- a/Projects/Domain/Sources/Base/BaseContentDetail.swift +++ b/Projects/Domain/Sources/Base/BaseContentDetail.swift @@ -16,6 +16,9 @@ public struct BaseContentDetail: Equatable { public let createdAt: String public var favorites: Bool? public var alertYn: RemindState + public let authorUserId: Int? + public let authorNickname: String? + public let authorProfileImageURL: String? public init( id: Int, @@ -25,7 +28,10 @@ public struct BaseContentDetail: Equatable { memo: String, createdAt: String, favorites: Bool?, - alertYn: RemindState + alertYn: RemindState, + authorUserId: Int? = nil, + authorNickname: String? = nil, + authorProfileImageURL: String? = nil ) { self.id = id self.category = category @@ -35,6 +41,9 @@ public struct BaseContentDetail: Equatable { self.createdAt = createdAt self.favorites = favorites self.alertYn = alertYn + self.authorUserId = authorUserId + self.authorNickname = authorNickname + self.authorProfileImageURL = authorProfileImageURL } } diff --git a/Projects/Domain/Sources/Base/BaseContentItem.swift b/Projects/Domain/Sources/Base/BaseContentItem.swift index bddcb66d..e253cece 100644 --- a/Projects/Domain/Sources/Base/BaseContentItem.swift +++ b/Projects/Domain/Sources/Base/BaseContentItem.swift @@ -22,6 +22,9 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta public var isRead: Bool? public var isFavorite: Bool? public let keyword: String? + public let authorUserId: Int? + public let authorNickname: String? + public let authorProfileImageURL: String? public init( id: Int, @@ -35,7 +38,10 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta createdAt: String, isRead: Bool?, isFavorite: Bool?, - keyword: String? = nil + keyword: String? = nil, + authorUserId: Int? = nil, + authorNickname: String? = nil, + authorProfileImageURL: String? = nil ) { self.id = id self.categoryName = categoryName @@ -49,5 +55,8 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta self.isRead = isRead self.isFavorite = isFavorite self.keyword = keyword + self.authorUserId = authorUserId + self.authorNickname = authorNickname + self.authorProfileImageURL = authorProfileImageURL } } diff --git a/Projects/Domain/Sources/Base/BaseReportReason.swift b/Projects/Domain/Sources/Base/BaseReportReason.swift new file mode 100644 index 00000000..938ff2c5 --- /dev/null +++ b/Projects/Domain/Sources/Base/BaseReportReason.swift @@ -0,0 +1,18 @@ +// +// BaseReportReason.swift +// Domain +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +public struct BaseReportReason: Equatable { + public let code: String + public let description: String + + public init(code: String, description: String) { + self.code = code + self.description = description + } +} diff --git a/Projects/Domain/Sources/CategorySharing/CategorySharing.swift b/Projects/Domain/Sources/CategorySharing/CategorySharing.swift index da404c8d..f25852b9 100644 --- a/Projects/Domain/Sources/CategorySharing/CategorySharing.swift +++ b/Projects/Domain/Sources/CategorySharing/CategorySharing.swift @@ -67,7 +67,40 @@ extension CategorySharing { public let thumbNail: String public let createdAt: String public let categoryName: String - public let isRead: Bool? = false - public let isFavorite: Bool? = false + public let isRead: Bool? + public let isFavorite: Bool? + public let authorUserId: Int? + public let authorNickname: String? + public let authorProfileImageURL: String? + + public init( + id: Int, + data: String, + domain: String, + title: String, + memo: String?, + thumbNail: String, + createdAt: String, + categoryName: String, + isRead: Bool? = false, + isFavorite: Bool? = false, + authorUserId: Int? = nil, + authorNickname: String? = nil, + authorProfileImageURL: String? = nil + ) { + self.id = id + self.data = data + self.domain = domain + self.title = title + self.memo = memo + self.thumbNail = thumbNail + self.createdAt = createdAt + self.categoryName = categoryName + self.isRead = isRead + self.isFavorite = isFavorite + self.authorUserId = authorUserId + self.authorNickname = authorNickname + self.authorProfileImageURL = authorProfileImageURL + } } } diff --git a/Projects/Domain/Sources/DTO/Alert/AlertListInquiryResponse+Extension.swift b/Projects/Domain/Sources/DTO/Alert/AlertListInquiryResponse+Extension.swift index cb784b5d..051646a4 100644 --- a/Projects/Domain/Sources/DTO/Alert/AlertListInquiryResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/Alert/AlertListInquiryResponse+Extension.swift @@ -27,6 +27,7 @@ public extension AlertItemInquiryResponse { id: self.id, userId: self.userId, contentId: self.contentId, + deeplink: self.deeplink, thumbNail: self.thumbNail, title: self.title, body: self.body, diff --git a/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift b/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift index 00c3622e..9ea93ee1 100644 --- a/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift @@ -24,7 +24,10 @@ public extension ContentBaseResponse { createdAt: self.createdAt, isRead: self.isRead, isFavorite: self.isFavorite, - keyword: self.keyword + keyword: self.keyword, + authorUserId: self.authorUserId, + authorNickname: self.authorNickname, + authorProfileImageURL: self.authorProfileImageURL ) } } diff --git a/Projects/Domain/Sources/DTO/Category/CategoryEditResponse+Extension.swift b/Projects/Domain/Sources/DTO/Category/CategoryEditResponse+Extension.swift index d23b7eef..d644ac81 100644 --- a/Projects/Domain/Sources/DTO/Category/CategoryEditResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/Category/CategoryEditResponse+Extension.swift @@ -14,7 +14,8 @@ public extension CategoryEditResponse { return .init( categoryId: self.categoryId, categoryName: self.categoryName, - categoryImage: self.categoryImage.toDomain() + categoryImage: self.categoryImage.toDomain(), + alertEnabled: self.alertEnabled ) } } diff --git a/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift b/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift index d4745561..af337bea 100644 --- a/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift +++ b/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift @@ -34,7 +34,8 @@ public extension CategoryItemInquiryResponse { openType: BaseOpenType(rawValue: self.openType) ?? .비공개, keywordType: BaseInterestType(rawValue: self.keywordType.slashConvertUnderBar) ?? .default, userCount: self.userCount + 1, - isFavorite: self.isFavorite + isFavorite: self.isFavorite, + alertEnabled: self.alertEnabled ) } } diff --git a/Projects/Domain/Sources/DTO/Category/SharedCategoryResponse+Extension.swift b/Projects/Domain/Sources/DTO/Category/SharedCategoryResponse+Extension.swift index 156f379f..458f2592 100644 --- a/Projects/Domain/Sources/DTO/Category/SharedCategoryResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/Category/SharedCategoryResponse+Extension.swift @@ -34,7 +34,10 @@ public extension SharedCategoryResponse.Content { memo: self.memo, thumbNail: self.thumbNail, createdAt: self.createdAt, - categoryName: categoryName + categoryName: categoryName, + authorUserId: self.authorUserId, + authorNickname: self.authorNickname, + authorProfileImageURL: self.authorProfileImageURL ) } } diff --git a/Projects/Domain/Sources/DTO/Content/ContentDetailResponse+Extension.swift b/Projects/Domain/Sources/DTO/Content/ContentDetailResponse+Extension.swift index 161830fc..d4808e00 100644 --- a/Projects/Domain/Sources/DTO/Content/ContentDetailResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/Content/ContentDetailResponse+Extension.swift @@ -23,6 +23,10 @@ public extension ContentDetailResponse { memo: self.memo, createdAt: self.createdAt, favorites: self.favorites, - alertYn: BaseContentDetail.RemindState(rawValue: self.alertYn) ?? .no) + alertYn: BaseContentDetail.RemindState(rawValue: self.alertYn) ?? .no, + authorUserId: self.authorUserId, + authorNickname: self.authorNickname ?? self.userNickname, + authorProfileImageURL: self.authorProfileImageURL + ) } } diff --git a/Projects/Domain/Sources/DTO/Content/ReportReasonResponse+Extension.swift b/Projects/Domain/Sources/DTO/Content/ReportReasonResponse+Extension.swift new file mode 100644 index 00000000..5f1fc945 --- /dev/null +++ b/Projects/Domain/Sources/DTO/Content/ReportReasonResponse+Extension.swift @@ -0,0 +1,22 @@ +// +// ReportReasonResponse+Extension.swift +// Domain +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +import CoreKit + +public extension ReportReasonResponse { + func toDomain() -> BaseReportReason { + .init(code: self.code, description: self.description) + } +} + +public extension Array where Element == ReportReasonResponse { + func toDomain() -> [BaseReportReason] { + map { $0.toDomain() } + } +} diff --git a/Projects/Domain/Sources/DTO/Notification/NotificationListInquiryResponse+Extension.swift b/Projects/Domain/Sources/DTO/Notification/NotificationListInquiryResponse+Extension.swift new file mode 100644 index 00000000..7a1ba2e7 --- /dev/null +++ b/Projects/Domain/Sources/DTO/Notification/NotificationListInquiryResponse+Extension.swift @@ -0,0 +1,22 @@ +// +// NotificationListInquiryResponse+Extension.swift +// Domain +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +import CoreKit + +public extension NotificationListInquiryResponse { + func toDomain() -> NotificationListInquiry { + .init( + data: self.data.map { $0.toDomain() }, + page: self.page, + size: self.size, + sort: self.sort.map { $0.toDomain() }, + hasNext: self.hasNext + ) + } +} diff --git a/Projects/Domain/Sources/DTO/Notification/NotificationResponse+Extension.swift b/Projects/Domain/Sources/DTO/Notification/NotificationResponse+Extension.swift new file mode 100644 index 00000000..db364127 --- /dev/null +++ b/Projects/Domain/Sources/DTO/Notification/NotificationResponse+Extension.swift @@ -0,0 +1,26 @@ +// +// NotificationResponse+Extension.swift +// Domain +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +import CoreKit + +public extension NotificationResponse { + func toDomain() -> NotificationItem { + .init( + id: self.id, + notificationType: self.notificationType, + title: self.title, + body: self.body, + categoryImageUrl: self.categoryImageUrl, + isRead: self.isRead, + navigationType: self.navigationType, + deepLink: self.deepLink, + createdAt: self.createdAt + ) + } +} diff --git a/Projects/Domain/Sources/Notification/NotificationItem.swift b/Projects/Domain/Sources/Notification/NotificationItem.swift new file mode 100644 index 00000000..6195e61d --- /dev/null +++ b/Projects/Domain/Sources/Notification/NotificationItem.swift @@ -0,0 +1,42 @@ +// +// NotificationItem.swift +// Domain +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +public struct NotificationItem: Identifiable, Equatable { + public let id: Int + public let notificationType: String + public let title: String + public let body: String + public let categoryImageUrl: String? + public var isRead: Bool + public let navigationType: String + public let deepLink: String? + public let createdAt: String + + public init( + id: Int, + notificationType: String, + title: String, + body: String, + categoryImageUrl: String?, + isRead: Bool, + navigationType: String, + deepLink: String?, + createdAt: String + ) { + self.id = id + self.notificationType = notificationType + self.title = title + self.body = body + self.categoryImageUrl = categoryImageUrl + self.isRead = isRead + self.navigationType = navigationType + self.deepLink = deepLink + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Sources/Notification/NotificationListInquiry.swift b/Projects/Domain/Sources/Notification/NotificationListInquiry.swift new file mode 100644 index 00000000..97f296a9 --- /dev/null +++ b/Projects/Domain/Sources/Notification/NotificationListInquiry.swift @@ -0,0 +1,32 @@ +// +// NotificationListInquiry.swift +// Domain +// +// Created by Codex on 2026-04-06. +// + +import Foundation + +import Util + +public struct NotificationListInquiry: Equatable { + public var data: [NotificationItem] + public let page: Int + public let size: Int + public let sort: [BaseItemInquirySort] + public let hasNext: Bool + + public init( + data: [NotificationItem], + page: Int, + size: Int, + sort: [BaseItemInquirySort], + hasNext: Bool + ) { + self.data = data + self.page = page + self.size = size + self.sort = sort + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift b/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift index 8cd549ca..9e2146b3 100644 --- a/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift +++ b/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift @@ -29,6 +29,8 @@ public struct PokitCategorySetting: Equatable { /// 카테고리 참여자 수 public let userCount: Int? + /// 해당 카테고리의 알림 수신 여부 + public var alertEnabled: Bool public init( categoryId: Int?, @@ -36,7 +38,8 @@ public struct PokitCategorySetting: Equatable { categoryImage: BaseCategoryImage?, openType: BaseOpenType?, keywordType: BaseInterestType?, - userCount: Int? = nil + userCount: Int? = nil, + alertEnabled: Bool = true ) { self.imageList = [] self.pageable = .init( @@ -50,5 +53,6 @@ public struct PokitCategorySetting: Equatable { self.openType = openType ?? .공개 self.keywordType = keywordType ?? .default self.userCount = userCount + self.alertEnabled = alertEnabled } } diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift index 5dd3f881..b90b9a00 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift @@ -35,8 +35,8 @@ public struct CategoryDetailFeature { @ObservableState public struct State: Equatable { /// Domain - fileprivate var domain: CategoryDetail - var category: BaseCategoryItem { + var domain: CategoryDetail + public var category: BaseCategoryItem { get { domain.category } } var isUnreadFiltered: Bool { @@ -97,6 +97,7 @@ public struct CategoryDetailFeature { } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -126,6 +127,7 @@ public struct CategoryDetailFeature { case 저장하기_버튼_눌렀을때 } + @CasePathable public enum InnerAction: Equatable { case 카테고리_시트_활성화(Bool) case 카테고리_선택_시트_활성화(Bool) @@ -144,6 +146,7 @@ public struct CategoryDetailFeature { case 내보낼_유저_선택(InvitedUser) } + @CasePathable public enum AsyncAction: Equatable { case 카테고리_내_컨텐츠_목록_조회_API case 카테고리_목록_조회_API @@ -156,6 +159,7 @@ public struct CategoryDetailFeature { case 공유받은_포킷_저장_API } + @CasePathable public enum ScopeAction { case categoryBottomSheet(PokitBottomSheet.Delegate) case categoryDeleteBottomSheet(PokitDeleteBottomSheet.Delegate) @@ -165,10 +169,12 @@ public struct CategoryDetailFeature { case contents(IdentifiedActionOf) } + @CasePathable public enum ParticipantsBottomSheetDelegate: Equatable { case removeParticipant(InvitedUser) } + @CasePathable public enum DelegateAction: Equatable { case contentItemTapped(BaseContentItem) case linkCopyDetected(URL?) @@ -464,7 +470,10 @@ private extension CategoryDetailFeature { createdAt: content.createdAt, isRead: false, isFavorite: false, - keyword: nil + keyword: nil, + authorUserId: content.authorUserId, + authorNickname: content.authorNickname, + authorProfileImageURL: content.authorProfileImageURL ) } @@ -534,7 +543,10 @@ private extension CategoryDetailFeature { createdAt: content.createdAt, isRead: false, isFavorite: false, - keyword: nil + keyword: nil, + authorUserId: content.authorUserId, + authorNickname: content.authorNickname, + authorProfileImageURL: content.authorProfileImageURL ) } diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift index 03961fe0..87762b02 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift @@ -48,6 +48,7 @@ public extension CategoryDetailView { } .listRowInsets(EdgeInsets(.zero)) } + .accessibilityIdentifier("category-detail-\(store.category.id)") .listStyle(.plain) .listRowSpacing(0) .refreshable { await send(.새로고침).finish() } @@ -141,6 +142,7 @@ private extension CategoryDetailView { .icon(.arrowLeft), action: { send(.dismiss) } ) + .accessibilityIdentifier("category-detail-back") } if !store.isFavoriteCategory && store.type == .참여 { @@ -362,6 +364,7 @@ private extension CategoryDetailView { isLast: isLast, showKebab: self.store.type == .참여 ) + .accessibilityIdentifier("content-card-\(store.state.content.id)") } else if store.content.isFavorite == true { ContentCardView( store: store, @@ -370,6 +373,7 @@ private extension CategoryDetailView { isLast: isLast, showKebab: self.store.type == .참여 ) + .accessibilityIdentifier("content-card-\(store.state.content.id)") } } diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/PokitParticipantsBottomSheet.swift b/Projects/Feature/FeatureCategoryDetail/Sources/PokitParticipantsBottomSheet.swift index b267f3a5..6d296767 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/PokitParticipantsBottomSheet.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/PokitParticipantsBottomSheet.swift @@ -40,6 +40,7 @@ struct PokitParticipantsBottomSheet: View { VStack(spacing: 0) { participantsList } + .accessibilityIdentifier("participants-sheet") .presentationDragIndicator(.visible) .presentationDetents([.height(height)]) .pokitPresentationCornerRadius() diff --git a/Projects/Feature/FeatureCategoryDetailDemo/Sources/FeatureCategoryDetailDemoApp.swift b/Projects/Feature/FeatureCategoryDetailDemo/Sources/FeatureCategoryDetailDemoApp.swift index 9424c7a4..382ef2ff 100644 --- a/Projects/Feature/FeatureCategoryDetailDemo/Sources/FeatureCategoryDetailDemoApp.swift +++ b/Projects/Feature/FeatureCategoryDetailDemo/Sources/FeatureCategoryDetailDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import ComposableArchitecture import FeatureCategoryDetail @@ -16,30 +17,32 @@ import CoreKit struct FeatureCategoryDetailDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 - NavigationStack { - CategoryDetailView( - store: Store( - initialState: .init( - category: BaseCategoryItem( - id: 764, - userId: 213, - categoryName: "playlist", - categoryImage: BaseCategoryImage( - imageId: 13, - imageURL: "https://pokit-s3.s3.ap-northeast-2.amazonaws.com/category-image/music.png" - ), - contentCount: 3, - createdAt: "2024.12.03", - openType: .공개, - keywordType: .음악, - userCount: 0, - isFavorite: false - ) - ), - reducer: { CategoryDetailFeature() } + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + NavigationStack { + CategoryDetailView( + store: Store( + initialState: .init( + category: BaseCategoryItem( + id: 764, + userId: 213, + categoryName: "playlist", + categoryImage: BaseCategoryImage( + imageId: 13, + imageURL: "https://pokit-s3.s3.ap-northeast-2.amazonaws.com/category-image/music.png" + ), + contentCount: 3, + createdAt: "2024.12.03", + openType: .공개, + keywordType: .음악, + userCount: 0, + isFavorite: false + ) + ), + reducer: { CategoryDetailFeature() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTestSupport.swift b/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTestSupport.swift new file mode 100644 index 00000000..fae38fb7 --- /dev/null +++ b/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTestSupport.swift @@ -0,0 +1,247 @@ +import Foundation + +import CoreKit +import Domain +import FeatureContentCard +import Util + +private func featureCategoryDetailDecode(_ value: Any) -> T { + let data = try! JSONSerialization.data(withJSONObject: value) + return try! JSONDecoder().decode(T.self, from: data) +} + +extension BaseCategoryItem { + static let featureCategoryDetail_sharedCategory = Self( + id: 55, + userId: 100, + categoryName: "공유 포킷", + categoryImage: .init(imageId: 501, imageURL: Constants.mockImageUrl), + contentCount: 3, + createdAt: "2026.04.06", + openType: .공개, + keywordType: .IT, + userCount: 3, + isFavorite: false + ) +} + +extension BaseContentItem { + static let featureCategoryDetail_sharedContent = Self( + id: 801, + categoryName: "공유 포킷", + categoryId: 55, + title: "공유 링크", + memo: "공유 메모", + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/category-detail", + domain: "pokit.link", + createdAt: "2026.04.06", + isRead: false, + isFavorite: true, + keyword: nil, + authorUserId: 201, + authorNickname: "참여멤버", + authorProfileImageURL: "https://example.com/category-detail-author.png" + ) + + static let featureCategoryDetail_participantPersonalizedContent = Self( + id: 802, + categoryName: "공유 포킷", + categoryId: 55, + title: "개인화 링크", + memo: "참여자 개인 메모", + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/personalized", + domain: "pokit.link", + createdAt: "2026.04.05", + isRead: true, + isFavorite: true, + keyword: nil, + authorUserId: 202, + authorNickname: "최근참여자", + authorProfileImageURL: "https://example.com/category-detail-latest.png" + ) +} + +extension ContentCardFeature.State { + static let featureCategoryDetail_sharedCard = Self(content: .featureCategoryDetail_sharedContent) +} + +extension SharedCategoryResponse { + static let featureCategoryDetail_sharedResponse: Self = featureCategoryDetailDecode([ + "category": [ + "categoryId": 55, + "categoryName": "공유 포킷", + "contentCount": 1, + "categoryImageId": 501, + "categoryImageUrl": Constants.mockImageUrl + ], + "contents": [ + "data": [[ + "contentId": 801, + "data": "https://pokit.link/category-detail", + "domain": "pokit.link", + "title": "공유 링크", + "memo": "공유 메모", + "thumbNail": Constants.mockImageUrl, + "createdAt": "2026-04-06T00:00:00Z", + "author": [ + "userId": 201, + "nickname": "참여멤버", + "profileImageUrl": "https://example.com/category-detail-author.png" + ] + ]], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ] + ]) +} + +extension InvitedUserResponse { + static let featureCategoryDetail_owner: Self = featureCategoryDetailDecode([ + "userId": 100, + "nickname": "방장", + "profileImage": [ + "id": 1, + "url": "https://example.com/owner.png" + ] + ]) + + static let featureCategoryDetail_member: Self = featureCategoryDetailDecode([ + "userId": 201, + "nickname": "참여멤버", + "profileImage": [ + "id": 2, + "url": "https://example.com/member.png" + ] + ]) + + static let featureCategoryDetail_latestMember: Self = featureCategoryDetailDecode([ + "userId": 202, + "nickname": "최근참여자", + "profileImage": [ + "id": 3, + "url": "https://example.com/latest-member.png" + ] + ]) +} + +extension Array where Element == InvitedUserResponse { + static let featureCategoryDetail_latestOrder: Self = [ + .featureCategoryDetail_latestMember, + .featureCategoryDetail_owner, + .featureCategoryDetail_member + ] + + static let featureCategoryDetail_ownerOnly: Self = [ + .featureCategoryDetail_owner + ] +} + +extension ContentListInquiryResponse { + static let featureCategoryDetail_participantListResponse: Self = featureCategoryDetailDecode([ + "data": [[ + "contentId": 802, + "category": [ + "categoryId": 55, + "categoryName": "공유 포킷" + ], + "data": "https://pokit.link/personalized", + "domain": "pokit.link", + "title": "개인화 링크", + "memo": "참여자 개인 메모", + "thumbNail": Constants.mockImageUrl, + "createdAt": "2026.04.05", + "isRead": true, + "isFavorite": true, + "keyword": NSNull(), + "author": [ + "userId": 202, + "nickname": "최근참여자", + "profileImageUrl": "https://example.com/category-detail-latest.png" + ] + ]], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension CategoryClient { + static func featureCategoryDetailTestValue( + sharedResponse: SharedCategoryResponse = .featureCategoryDetail_sharedResponse, + invitedUsers: [InvitedUserResponse] = [ + .featureCategoryDetail_owner, + .featureCategoryDetail_member + ], + onAcceptInvite: (@Sendable (Int) async throws -> Void)? = nil, + onSaveShared: (@Sendable (CopiedCategoryRequest) async throws -> Void)? = nil, + onRemoveParticipant: (@Sendable (Int, Int) async throws -> Void)? = nil, + onLeave: (@Sendable (Int) async throws -> Void)? = nil + ) -> Self { + var client = Self.testValue + client.공유받은_카테고리_조회 = { _, _ in sharedResponse } + client.포킷_초대된_유저_목록_조회 = { _ in invitedUsers } + client.포킷_초대_수락 = { id in + if let onAcceptInvite { + try await onAcceptInvite(id) + } + } + client.공유받은_카테고리_저장 = { request in + if let onSaveShared { + try await onSaveShared(request) + } + } + client.포킷_내보내기 = { categoryId, userId in + if let onRemoveParticipant { + try await onRemoveParticipant(categoryId, userId) + } + } + client.포킷_나가기 = { categoryId in + if let onLeave { + try await onLeave(categoryId) + } + } + return client + } +} + +extension ContentClient { + static func featureCategoryDetailTestValue( + participantListResponse: ContentListInquiryResponse = .featureCategoryDetail_participantListResponse + ) -> Self { + var client = Self.testValue + client.카테고리_내_컨텐츠_목록_조회 = { _, _, _ in participantListResponse } + return client + } +} + +extension UserDefaultsClient { + static func featureCategoryDetailTestValue(currentUserId: Int?) -> Self { + var client = Self.testValue + client.stringKey = { key in + switch key { + case .userId: + return currentUserId.map(String.init) + default: + return nil + } + } + return client + } +} diff --git a/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTests.swift b/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTests.swift index 8a0522f1..0c38614f 100644 --- a/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTests.swift +++ b/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTests.swift @@ -1,10 +1,279 @@ import ComposableArchitecture -import XCTest +import CoreKit +import DSKit +import Domain +import FeatureContentCard +import Testing @testable import FeatureCategoryDetail -final class FeatureCategoryDetailTests: XCTestCase { - func test() { - +@MainActor +struct FeatureCategoryDetailTests { + @Test("케밥버튼을 누르면 카테고리시트를 노출한다") + func 케밥버튼을_누르면_카테고리시트를_노출한다() async throws { + let store = TestStore(initialState: CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + )) { + CategoryDetailFeature() + } + + await store.send(.view(.카테고리_케밥_버튼_눌렀을때)) + await store.receive(\.inner.카테고리_시트_활성화) { + $0.isCategorySheetPresented = true + } + } + + @Test("참여인원 버튼을 누르면 참여인원시트를 노출한다") + func 참여인원_버튼을_누르면_참여인원시트를_노출한다() async throws { + let store = TestStore(initialState: CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + )) { + CategoryDetailFeature() + } + + await store.send(.view(.참여인원_버튼_눌렀을때)) + await store.receive(\.inner.참여인원_시트_활성화) { + $0.isParticipantsSheetPresented = true + } + } + + @Test("참여자 내보내기 선택시 확인시트와 선택유저가 갱신된다") + func 참여자_내보내기_선택시_확인시트와_선택유저가_갱신된다() async throws { + var initialState = CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + ) + initialState.isParticipantsSheetPresented = true + + let store = TestStore(initialState: initialState) { + CategoryDetailFeature() + } + + let member = InvitedUserResponse.featureCategoryDetail_member.toDomain() + await store.send(.scope(.participantsBottomSheet(.removeParticipant(member)))) + await store.receive(\.inner.참여인원_시트_활성화) { + $0.isParticipantsSheetPresented = false + } + await store.receive(\.inner.내보낼_유저_선택) { + $0.selectedUserToRemove = member + } + await store.receive(\.inner.내보내기_확인_시트_활성화) { + $0.isRemoveParticipantSheetPresented = true + } + } + + @Test("내보내기 확정시 API후 참여인원목록을 재조회한다") + func 내보내기_확정시_API후_참여인원목록을_재조회한다() async throws { + var initialState = CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + ) + initialState.selectedUserToRemove = InvitedUserResponse.featureCategoryDetail_member.toDomain() + initialState.isRemoveParticipantSheetPresented = true + + let store = TestStore(initialState: initialState) { + CategoryDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategoryDetailTestValue( + invitedUsers: [InvitedUserResponse].featureCategoryDetail_ownerOnly, + onRemoveParticipant: { categoryId, userId in + guard categoryId == 55, userId == 201 else { + preconditionFailure("예상하지 못한 내보내기 요청입니다: \(categoryId), \(userId)") + } + } + ) + } + + await store.send(.scope(.removeParticipantBottomSheet(.deleteButtonTapped))) + await store.receive(\.async.포킷_내보내기_API) + await store.receive(\.inner.내보내기_확인_시트_활성화) { + $0.isRemoveParticipantSheetPresented = false + } + await store.receive(\.async.포킷_초대된_유저_목록_조회_API) + await store.receive(\.inner.포킷_초대된_유저_목록_조회_API_반영) { + $0.domain.invitedUsers = [InvitedUserResponse].featureCategoryDetail_ownerOnly.map { $0.toDomain() } + } + } + + @Test("초대 수락하기는 타입을 참여로 바꾸고 delegate를 보낸다") + func 초대_수락하기는_타입을_참여로_바꾸고_delegate를_보낸다() async throws { + let store = TestStore(initialState: CategoryDetailFeature.State( + type: .초대, + category: .featureCategoryDetail_sharedCategory + )) { + CategoryDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategoryDetailTestValue( + onAcceptInvite: { categoryId in + guard categoryId == 55 else { + preconditionFailure("예상하지 못한 초대 수락 포킷 ID입니다: \(categoryId)") + } + } + ) + } + + await store.send(.view(.초대_수락하기_버튼_눌렀을때)) + await store.receive(\.async.포킷_초대_수락_API) + await store.receive(\.inner.타입_변경) { + $0.type = .참여 + } + await store.receive(\.delegate.초대_수락_완료) + } + + @Test("공유받은 포킷 저장은 타입을 참여로 바꾸고 delegate를 보낸다") + func 공유받은_포킷_저장은_타입을_참여로_바꾸고_delegate를_보낸다() async throws { + let store = TestStore(initialState: CategoryDetailFeature.State( + type: .공유, + category: .featureCategoryDetail_sharedCategory + )) { + CategoryDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategoryDetailTestValue( + onSaveShared: { request in + guard request.originCategoryId == 55 else { + preconditionFailure("예상하지 못한 공유 포킷 저장 요청입니다: \(request.originCategoryId)") + } + } + ) + } + + await store.send(.view(.저장하기_버튼_눌렀을때)) + await store.receive(\.async.공유받은_포킷_저장_API) + await store.receive(\.inner.타입_변경) { + $0.type = .참여 + } + await store.receive(\.delegate.저장_완료) + } + + @Test("참여자인원이 최신참여순으로 반영된다") + func 참여자인원이_최신참여순으로_반영된다() async throws { + let store = TestStore(initialState: CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + )) { + CategoryDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategoryDetailTestValue( + invitedUsers: [InvitedUserResponse].featureCategoryDetail_latestOrder + ) + } + + await store.send(.async(.포킷_초대된_유저_목록_조회_API)) + await store.receive(\.inner.포킷_초대된_유저_목록_조회_API_반영) { + $0.domain.invitedUsers = [InvitedUserResponse].featureCategoryDetail_latestOrder.map { $0.toDomain() } + guard $0.invitedUsers.map(\.id) == [202, 100, 201] else { + preconditionFailure("참여 인원 정렬이 예상과 다릅니다: \($0.invitedUsers.map(\.id))") + } + } + } + + @Test("공유포킷 컨텐츠조회시 author정보를 보존한다") + func 공유포킷_컨텐츠조회시_author정보를_보존한다() async throws { + let store = TestStore(initialState: CategoryDetailFeature.State( + type: .공유, + category: .featureCategoryDetail_sharedCategory + )) { + CategoryDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategoryDetailTestValue() + } + store.exhaustivity = .off + + await store.send(.async(.카테고리_내_컨텐츠_목록_조회_API)) + await store.receive(\.inner.카테고리_내_컨텐츠_목록_조회_API_반영) { + $0.isLoading = false + guard let firstContent = $0.contents.first?.content else { + preconditionFailure("컨텐츠가 비어 있습니다") + } + guard + firstContent.authorNickname == "참여멤버", + firstContent.authorUserId == 201, + firstContent.authorProfileImageURL == "https://example.com/category-detail-author.png" + else { + preconditionFailure("author 정보가 보존되지 않았습니다: \(firstContent)") + } + } + } + + @Test("참여중인 공유포킷은 개인별 안읽음과 즐겨찾기를 보존한다") + func 참여중인_공유포킷은_개인별_안읽음과_즐겨찾기를_보존한다() async throws { + let response = ContentListInquiryResponse.featureCategoryDetail_participantListResponse + + let store = TestStore(initialState: CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + )) { + CategoryDetailFeature() + } withDependencies: { + $0[ContentClient.self] = .featureCategoryDetailTestValue( + participantListResponse: response + ) + } + store.exhaustivity = .off + + await store.send(.async(.카테고리_내_컨텐츠_목록_조회_API)) + await store.receive(\.inner.카테고리_내_컨텐츠_목록_조회_API_반영) { + $0.isLoading = false + guard let firstContent = $0.contents.first?.content else { + preconditionFailure("컨텐츠가 비어 있습니다") + } + guard + firstContent.isRead == true, + firstContent.isFavorite == true + else { + preconditionFailure("공유 포킷의 개인별 안읽음/즐겨찾기 상태가 보존되지 않았습니다: \(firstContent)") + } + } + } + + @Test("나가기 선택시 확인시트가 노출된다") + func 나가기_선택시_확인시트가_노출된다() async throws { + var initialState = CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + ) + initialState.isCategorySheetPresented = true + + let store = TestStore(initialState: initialState) { + CategoryDetailFeature() + } + + await store.send(.scope(.categoryBottomSheet(.leaveCellButtonTapped))) + await store.receive(\.inner.카테고리_시트_활성화) { + $0.isCategorySheetPresented = false + } + await store.receive(\.inner.나가기_확인_시트_활성화) { + $0.isLeaveSheetPresented = true + } + } + + @Test("나가기 확정시 API후 delegate를 보낸다") + func 나가기_확정시_API후_delegate를_보낸다() async throws { + var initialState = CategoryDetailFeature.State( + type: .참여, + category: .featureCategoryDetail_sharedCategory + ) + initialState.isLeaveSheetPresented = true + + let store = TestStore(initialState: initialState) { + CategoryDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategoryDetailTestValue( + onLeave: { categoryId in + guard categoryId == 55 else { + preconditionFailure("예상하지 못한 포킷 나가기 요청입니다: \(categoryId)") + } + } + ) + } + + await store.send(.scope(.leaveBottomSheet(.deleteButtonTapped))) + await store.receive(\.async.포킷_나가기_API) + await store.receive(\.inner.나가기_확인_시트_활성화) { + $0.isLeaveSheetPresented = false + } + await store.receive(\.delegate.포킷나가기) } } diff --git a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift index 3774cef2..7a3aa37f 100644 --- a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift +++ b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift @@ -112,13 +112,16 @@ public struct PokitCategorySettingFeature { categoryImage: category?.categoryImage, openType: category?.openType, keywordType: category?.keywordType, - userCount: category?.userCount + userCount: category?.userCount, + alertEnabled: category?.alertEnabled ?? true ) self.categoryUserId = category?.userId + self.isAlert = category?.alertEnabled ?? true } } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -142,6 +145,7 @@ public struct PokitCategorySettingFeature { case scenePhase_바꼈을때(ScenePhase) } + @CasePathable public enum InnerAction: Equatable { case 프로필_목록_조회_API_반영(images: [BaseCategoryImage]) case 포킷_오류_핸들링(BaseError) @@ -150,6 +154,7 @@ public struct PokitCategorySettingFeature { case 알림_권한_감지_반영(Bool) } + @CasePathable public enum AsyncAction: Equatable { case 프로필_목록_조회_API case 클립보드_감지 @@ -157,10 +162,12 @@ public struct PokitCategorySettingFeature { case 알림_권한_감지 } + @CasePathable public enum ScopeAction { case profile(PokitProfileBottomSheet.Delegate) } + @CasePathable public enum DelegateAction: Equatable { /// 이전화면으로 돌아가 카테고리 항목을 추가하면됨 case settingSuccess @@ -231,7 +238,8 @@ private extension PokitCategorySettingFeature { return .none } else { return .run { [domain = state.domain, - type = state.type] send in + type = state.type, + alertEnabled = state.isAlert] send in switch type { case .추가: guard let image = domain.categoryImage else { return } @@ -256,7 +264,8 @@ private extension PokitCategorySettingFeature { openType: domain.openType, keywordType: domain.keywordType, userCount: 0, - isFavorite: false + isFavorite: false, + alertEnabled: response.alertEnabled ) await send(.inner(.카테고리_인메모리_저장(responseToCategoryDomain))) await send(.delegate(.settingSuccess)) @@ -268,7 +277,8 @@ private extension PokitCategorySettingFeature { categoryName: domain.categoryName, categoryImageId: image.id, openType: domain.openType.title, - keywordType: domain.keywordType.title + keywordType: domain.keywordType.title, + alertEnabled: alertEnabled ) let _ = try await categoryClient.카테고리_수정(categoryId, request) await send(.delegate(.settingSuccess)) @@ -308,7 +318,8 @@ private extension PokitCategorySettingFeature { return .merge( .send(.async(.프로필_목록_조회_API)), .send(.async(.클립보드_감지)), - .send(.async(.키보드_감지)) + .send(.async(.키보드_감지)), + .send(.async(.알림_권한_감지)) ) case .포킷명지우기_버튼_눌렀을때: state.domain.categoryName = "" @@ -328,6 +339,7 @@ private extension PokitCategorySettingFeature { case let .알림_권한_바인딩(isAlert): state.isAlert = isAlert + state.domain.alertEnabled = isAlert guard isAlert && !state.isNotificationAuthorization else { return .none } state.showAlertSheet = true return .none diff --git a/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift b/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift index 19d49f36..89541348 100644 --- a/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift +++ b/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import ComposableArchitecture import FeatureCategorySetting @@ -17,34 +18,36 @@ import Domain struct FeatureCategorySettingDemoApp: App { var body: some Scene { WindowGroup { - DemoView(store: .init( - initialState: DemoFeature.State(), - reducer: { DemoFeature() } - )) { - NavigationStack { - PokitCategorySettingView( - store: Store( - initialState: .init( - type: .수정, - category: BaseCategoryItem( - id: 764, - userId: 213, - categoryName: "playlist", - categoryImage: BaseCategoryImage( - imageId: 13, - imageURL: "https://pokit-s3.s3.ap-northeast-2.amazonaws.com/category-image/music.png" - ), - contentCount: 3, - createdAt: "2024.12.03", - openType: .공개, - keywordType: .음악, - userCount: 2, - isFavorite: false - ) - ), - reducer: { PokitCategorySettingFeature() } + if !_XCTIsTesting { + DemoView(store: .init( + initialState: DemoFeature.State(), + reducer: { DemoFeature() } + )) { + NavigationStack { + PokitCategorySettingView( + store: Store( + initialState: .init( + type: .수정, + category: BaseCategoryItem( + id: 764, + userId: 213, + categoryName: "playlist", + categoryImage: BaseCategoryImage( + imageId: 13, + imageURL: "https://pokit-s3.s3.ap-northeast-2.amazonaws.com/category-image/music.png" + ), + contentCount: 3, + createdAt: "2024.12.03", + openType: .공개, + keywordType: .음악, + userCount: 2, + isFavorite: false + ) + ), + reducer: { PokitCategorySettingFeature() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTestSupport.swift b/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTestSupport.swift new file mode 100644 index 00000000..6d90f3b6 --- /dev/null +++ b/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTestSupport.swift @@ -0,0 +1,100 @@ +import CoreKit +import Domain +import Util +import UserNotifications + +extension BaseCategoryImage { + static let featureCategorySetting_image = Self( + imageId: 7, + imageURL: Constants.mockImageUrl + ) +} + +extension BaseCategoryItem { + static let featureCategorySetting_editTarget = Self( + id: 42, + userId: 100, + categoryName: "공유 포킷", + categoryImage: .featureCategorySetting_image, + contentCount: 3, + createdAt: "2026-05-19", + openType: .공개, + keywordType: .IT, + userCount: 2, + isFavorite: false, + alertEnabled: true + ) + + static let featureCategorySetting_alertDisabledEditTarget = Self( + id: 43, + userId: 100, + categoryName: "알림 꺼진 포킷", + categoryImage: .featureCategorySetting_image, + contentCount: 3, + createdAt: "2026-05-19", + openType: .공개, + keywordType: .IT, + userCount: 2, + isFavorite: false, + alertEnabled: false + ) +} + +extension CategoryClient { + static func featureCategorySettingTestValue( + onUpdate: (@Sendable (Int, CategoryEditRequest) async throws -> CategoryEditResponse)? = nil, + onProfileList: (@Sendable () async throws -> [CategoryImageResponse])? = nil + ) -> Self { + Self( + 카테고리_삭제: { _ in }, + 카테고리_수정: { categoryId, request in + if let onUpdate { + return try await onUpdate(categoryId, request) + } + return .mock + }, + 카테고리_목록_조회: { _, _, _ in .mock }, + 카테고리_생성: { _ in .mock }, + 카테고리_프로필_목록_조회: { + if let onProfileList { + return try await onProfileList() + } + return CategoryImageResponse.mock + }, + 유저_카테고리_개수_조회: { .mock }, + 카테고리_상세_조회: { _ in .mock }, + 공유받은_카테고리_조회: { _, _ in .mock }, + 공유받은_카테고리_저장: { _ in }, + 포킷_초대된_유저_목록_조회: { _ in [.mock] }, + 포킷_내보내기: { _, _ in }, + 포킷_나가기: { _ in }, + 포킷_초대_수락: { _ in } + ) + } +} + +extension UserNotificationClient { + static func featureCategorySettingTestValue( + authorizationStatus: UNAuthorizationStatus + ) -> Self { + var client = Self.noop + client.getNotificationSettings = { + Notification.Settings(authorizationStatus: authorizationStatus) + } + return client + } +} + +extension UserDefaultsClient { + static let featureCategorySettingTestValue = Self( + boolKey: { _ in false }, + stringKey: { _ in nil }, + stringArrayKey: { _ in [] }, + removeBool: { _ in }, + removeString: { _ in }, + removeStringArray: { _ in }, + setBool: { _, _ in }, + setString: { _, _ in }, + setStringArray: { _, _ in } + ) +} diff --git a/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTests.swift b/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTests.swift index b4eb7253..f5c0825d 100644 --- a/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTests.swift +++ b/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTests.swift @@ -1,10 +1,122 @@ import ComposableArchitecture +import CoreKit +import Domain +import Util import XCTest @testable import FeatureCategorySetting +@MainActor final class FeatureCategorySettingTests: XCTestCase { - func test() { - + func test_화면_진입시_시스템_알림_권한을_조회한다() async { + let store = TestStore( + initialState: PokitCategorySettingFeature.State( + type: .수정, + category: .featureCategorySetting_editTarget + ) + ) { + PokitCategorySettingFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategorySettingTestValue( + onProfileList: { [] } + ) + $0[UserNotificationClient.self] = .featureCategorySettingTestValue( + authorizationStatus: .authorized + ) + $0[UserDefaultsClient.self] = .featureCategorySettingTestValue + $0[PasteboardClient.self] = .noop + $0[KeyboardClient.self] = .noop + } + store.exhaustivity = .off + + await store.send(.view(.뷰가_나타났을때)) { + $0.keywordSelectType = .select(keywordName: BaseInterestType.IT.title) + } + await store.receive(\.async.프로필_목록_조회_API) + await store.receive(\.async.클립보드_감지) + await store.receive(\.async.키보드_감지) + await store.receive(\.async.알림_권한_감지) + await store.receive(\.inner.알림_권한_감지_반영) { + $0.isNotificationAuthorization = true + } + } + + func test_카테고리_조회_alertEnabled_false가_수정화면_토글에_반영된다() async { + let store = TestStore( + initialState: PokitCategorySettingFeature.State( + type: .수정, + category: .featureCategorySetting_alertDisabledEditTarget + ) + ) { + PokitCategorySettingFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategorySettingTestValue( + onProfileList: { [] } + ) + $0[UserNotificationClient.self] = .featureCategorySettingTestValue( + authorizationStatus: .authorized + ) + $0[UserDefaultsClient.self] = .featureCategorySettingTestValue + $0[PasteboardClient.self] = .noop + $0[KeyboardClient.self] = .noop + } + store.exhaustivity = .off + + await store.send(.view(.뷰가_나타났을때)) { + guard $0.isAlert == false else { + preconditionFailure("서버 alertEnabled=false가 수정 화면 토글에 반영되어야 합니다.") + } + $0.keywordSelectType = .select(keywordName: BaseInterestType.IT.title) + } + await store.receive(\.async.프로필_목록_조회_API) + await store.receive(\.async.클립보드_감지) + await store.receive(\.async.키보드_감지) + await store.receive(\.async.알림_권한_감지) + await store.receive(\.inner.알림_권한_감지_반영) { + $0.isNotificationAuthorization = true + guard $0.alertEnable == false else { + preconditionFailure("시스템 알림 권한이 있어도 서버 알림 설정이 꺼져 있으면 토글은 꺼짐 상태여야 합니다.") + } + } + } + + func test_카테고리_수정시_alertEnabled를_요청에_포함한다() async { + let store = TestStore( + initialState: PokitCategorySettingFeature.State( + type: .수정, + category: .featureCategorySetting_editTarget + ) + ) { + PokitCategorySettingFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategorySettingTestValue( + onUpdate: { categoryId, request in + guard categoryId == 42 else { + preconditionFailure("예상하지 못한 카테고리 ID입니다: \(categoryId)") + } + guard request.alertEnabled == false else { + preconditionFailure("alertEnabled가 false로 전달되어야 합니다: \(String(describing: request.alertEnabled))") + } + guard request.categoryName == "공유 포킷", + request.categoryImageId == 7, + request.openType == "PUBLIC", + request.keywordType == "IT" else { + preconditionFailure("카테고리 수정 요청 값이 예상과 다릅니다: \(request)") + } + return .mock + } + ) + } + store.exhaustivity = .off + + await store.send(.view(.알림_권한_바인딩(false))) { + $0.isAlert = false + } + await store.send(.view(.저장_버튼_눌렀을때)) + await store.receive(\.delegate.settingSuccess) { + guard $0.isAlert == false else { + preconditionFailure("알림 토글 상태가 저장 요청 이후에도 유지되어야 합니다.") + } + } } } diff --git a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift index fb4d1b5b..c86dfa09 100644 --- a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift +++ b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift @@ -25,7 +25,7 @@ public struct CategorySharingFeature { /// - State @ObservableState public struct State: Equatable { - fileprivate var domain: CategorySharing + var domain: CategorySharing var category: CategorySharing.Category { domain.sharedCategory.category } var contents: IdentifiedArrayOf = [] var hasNext: Bool { domain.sharedCategory.contentList.hasNext } @@ -65,12 +65,14 @@ public struct CategorySharingFeature { case 뷰가_나타났을때 } + @CasePathable public enum InnerAction: Equatable { case 공유받은_카테고리_API_반영(CategorySharing.SharedCategory) case 경고_닫음 case 경고_띄움(BaseError) } + @CasePathable public enum AsyncAction: Equatable { case 공유받은_카테고리_조회_API } @@ -79,6 +81,7 @@ public struct CategorySharingFeature { case contents(IdentifiedActionOf) } + @CasePathable public enum DelegateAction: Equatable { case 컨텐츠_아이템_클릭(categoryId: Int, content: CategorySharing.Content) case 공유받은_카테고리_추가(sharedCategory: CategorySharing.Category) @@ -158,7 +161,10 @@ private extension CategorySharingFeature { domain: content.domain, createdAt: content.createdAt, isRead: content.isRead, - isFavorite: content.isFavorite + isFavorite: content.isFavorite, + authorUserId: content.authorUserId, + authorNickname: content.authorNickname, + authorProfileImageURL: content.authorProfileImageURL ))) } state.isLoading = false @@ -184,7 +190,10 @@ private extension CategorySharingFeature { domain: content.domain, createdAt: content.createdAt, isRead: content.isRead, - isFavorite: content.isFavorite + isFavorite: content.isFavorite, + authorUserId: content.authorUserId, + authorNickname: content.authorNickname, + authorProfileImageURL: content.authorProfileImageURL ))) } state.isLoading = false diff --git a/Projects/Feature/FeatureCategorySharingDemo/Sources/FeatureCategorySharingDemoApp.swift b/Projects/Feature/FeatureCategorySharingDemo/Sources/FeatureCategorySharingDemoApp.swift index 86b00c74..e8351128 100644 --- a/Projects/Feature/FeatureCategorySharingDemo/Sources/FeatureCategorySharingDemoApp.swift +++ b/Projects/Feature/FeatureCategorySharingDemo/Sources/FeatureCategorySharingDemoApp.swift @@ -6,12 +6,15 @@ // import SwiftUI +import XCTestDynamicOverlay @main struct FeatureCategorySharingDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + } } } } diff --git a/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTestSupport.swift b/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTestSupport.swift new file mode 100644 index 00000000..c8026f45 --- /dev/null +++ b/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTestSupport.swift @@ -0,0 +1,265 @@ +import Foundation + +import CoreKit +import Domain +import FeatureContentCard + +private func featureCategorySharingDecode(_ value: Any) -> T { + let data = try! JSONSerialization.data(withJSONObject: value) + return try! JSONDecoder().decode(T.self, from: data) +} + +extension BaseContentItem { + static let featureCategorySharing_first = Self( + id: 701, + categoryName: "공유 포킷", + categoryId: 55, + title: "공유 링크 1", + memo: "메모 1", + thumbNail: "https://example.com/thumb-1.png", + data: "https://pokit.link/shared-1", + domain: "pokit.link", + createdAt: "2026-04-06T00:00:00Z", + isRead: false, + isFavorite: false, + keyword: nil, + authorUserId: 91, + authorNickname: "공유멤버1", + authorProfileImageURL: "https://example.com/author-1.png" + ) + + static let featureCategorySharing_second = Self( + id: 702, + categoryName: "공유 포킷", + categoryId: 55, + title: "공유 링크 2", + memo: "메모 2", + thumbNail: "https://example.com/thumb-2.png", + data: "https://pokit.link/shared-2", + domain: "pokit.link", + createdAt: "2026-04-05T00:00:00Z", + isRead: false, + isFavorite: false, + keyword: nil, + authorUserId: 92, + authorNickname: "공유멤버2", + authorProfileImageURL: "https://example.com/author-2.png" + ) + + static let featureCategorySharing_third = Self( + id: 703, + categoryName: "공유 포킷", + categoryId: 55, + title: "공유 링크 3", + memo: "메모 3", + thumbNail: "https://example.com/thumb-3.png", + data: "https://pokit.link/shared-3", + domain: "pokit.link", + createdAt: "2026-04-04T00:00:00Z", + isRead: false, + isFavorite: false, + keyword: nil, + authorUserId: 93, + authorNickname: "공유멤버3", + authorProfileImageURL: "https://example.com/author-3.png" + ) +} + +extension ContentCardFeature.State { + static let featureCategorySharing_firstCard = Self(content: .featureCategorySharing_first) + static let featureCategorySharing_secondCard = Self(content: .featureCategorySharing_second) + static let featureCategorySharing_thirdCard = Self(content: .featureCategorySharing_third) +} + +extension SharedCategoryResponse { + static let featureCategorySharing_initialResponse: Self = featureCategorySharingDecode([ + "category": [ + "categoryId": 55, + "categoryName": "공유 포킷", + "contentCount": 2, + "categoryImageId": 501, + "categoryImageUrl": "https://example.com/shared-category.png" + ], + "contents": [ + "data": [ + [ + "contentId": 701, + "data": "https://pokit.link/shared-1", + "domain": "pokit.link", + "title": "공유 링크 1", + "memo": "메모 1", + "thumbNail": "https://example.com/thumb-1.png", + "createdAt": "2026-04-06T00:00:00Z", + "author": [ + "userId": 91, + "nickname": "공유멤버1", + "profileImageUrl": "https://example.com/author-1.png" + ] + ], + [ + "contentId": 702, + "data": "https://pokit.link/shared-2", + "domain": "pokit.link", + "title": "공유 링크 2", + "memo": "메모 2", + "thumbNail": "https://example.com/thumb-2.png", + "createdAt": "2026-04-05T00:00:00Z", + "author": [ + "userId": 92, + "nickname": "공유멤버2", + "profileImageUrl": "https://example.com/author-2.png" + ] + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": true + ] + ]) + + static let featureCategorySharing_nextPageResponse: Self = featureCategorySharingDecode([ + "category": [ + "categoryId": 55, + "categoryName": "공유 포킷", + "contentCount": 3, + "categoryImageId": 501, + "categoryImageUrl": "https://example.com/shared-category.png" + ], + "contents": [ + "data": [[ + "contentId": 703, + "data": "https://pokit.link/shared-3", + "domain": "pokit.link", + "title": "공유 링크 3", + "memo": "메모 3", + "thumbNail": "https://example.com/thumb-3.png", + "createdAt": "2026-04-04T00:00:00Z", + "author": [ + "userId": 93, + "nickname": "공유멤버3", + "profileImageUrl": "https://example.com/author-3.png" + ] + ]], + "page": 1, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ] + ]) +} + +// MARK: - TC-15: 초대 수락 시나리오 mock + +extension BaseContentItem { + static let featureCategorySharing_invite = Self( + id: 710, + categoryName: "초대 포킷", + categoryId: 60, + title: "초대 링크 1", + memo: "초대 메모", + thumbNail: "https://example.com/thumb-invite.png", + data: "https://pokit.link/invite-1", + domain: "pokit.link", + createdAt: "2026-04-07T00:00:00Z", + isRead: false, + isFavorite: false, + keyword: nil, + authorUserId: 95, + authorNickname: "초대자", + authorProfileImageURL: "https://example.com/author-invite.png" + ) +} + +extension ContentCardFeature.State { + static let featureCategorySharing_inviteCard = Self( + content: .featureCategorySharing_invite + ) +} + +extension SharedCategoryResponse { + // TC-15: 초대 수락 응답 + static let featureCategorySharing_inviteResponse: Self = featureCategorySharingDecode([ + "category": [ + "categoryId": 60, + "categoryName": "초대 포킷", + "contentCount": 1, + "categoryImageId": 510, + "categoryImageUrl": "https://example.com/invite-category.png" + ], + "contents": [ + "data": [ + [ + "contentId": 710, + "data": "https://pokit.link/invite-1", + "domain": "pokit.link", + "title": "초대 링크 1", + "memo": "초대 메모", + "thumbNail": "https://example.com/thumb-invite.png", + "createdAt": "2026-04-07T00:00:00Z", + "author": [ + "userId": 95, + "nickname": "초대자", + "profileImageUrl": "https://example.com/author-invite.png" + ] + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ] + ]) + + // TC-17: 마지막 참여자 응답 (컨텐츠 0개) + static let featureCategorySharing_lastParticipantResponse: Self = featureCategorySharingDecode([ + "category": [ + "categoryId": 70, + "categoryName": "나가기 포킷", + "contentCount": 0, + "categoryImageId": 520, + "categoryImageUrl": "https://example.com/leave-category.png" + ], + "contents": [ + "data": [] as [[String: Any]], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ] + ]) +} + +extension CategoryClient { + static func featureCategorySharingTestValue( + sharedResponse: SharedCategoryResponse = .featureCategorySharing_nextPageResponse + ) -> Self { + var client = Self.testValue + client.공유받은_카테고리_조회 = { _, _ in sharedResponse } + return client + } +} diff --git a/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTests.swift b/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTests.swift index e9e2b090..a34d0b1f 100644 --- a/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTests.swift +++ b/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTests.swift @@ -1,10 +1,162 @@ import ComposableArchitecture -import XCTest +import CoreKit +import Domain +import Testing @testable import FeatureCategorySharing -final class FeatureCategorySharingTests: XCTestCase { - func test() { - +@MainActor +struct FeatureCategorySharingTests { + @Test("뷰가 나타났을때 공유컨텐츠를 ContentCardState로 변환하고 author정보를 보존한다") + func 뷰가_나타났을때_공유컨텐츠를_ContentCardState로_변환하고_author정보를_보존한다() async throws { + let store = TestStore( + initialState: CategorySharingFeature.State( + sharedCategory: SharedCategoryResponse.featureCategorySharing_initialResponse.toDomain() + ) + ) { + CategorySharingFeature() + } + store.exhaustivity = .off + + await store.send(.view(.뷰가_나타났을때)) { + $0.isLoading = false + guard $0.contents.count == 2 else { + preconditionFailure("컨텐츠가 2개여야 합니다: \($0.contents.count)") + } + guard + $0.contents[0].content.authorNickname == "공유멤버1", + $0.contents[0].content.authorUserId == 91, + $0.contents[1].content.authorNickname == "공유멤버2", + $0.contents[1].content.authorUserId == 92 + else { + preconditionFailure("author 정보가 보존되지 않았습니다") + } + } + } + + @Test("저장버튼을 누르면 공유카테고리 추가 delegate를 보낸다") + func 저장버튼을_누르면_공유카테고리_추가_delegate를_보낸다() async throws { + let store = TestStore( + initialState: CategorySharingFeature.State( + sharedCategory: SharedCategoryResponse.featureCategorySharing_initialResponse.toDomain() + ) + ) { + CategorySharingFeature() + } + + await store.send(.view(.저장_버튼_눌렀을때)) + await store.receive(\.delegate.공유받은_카테고리_추가) + } + + @Test("페이지 로딩중이면 다음페이지를 조회해 컨텐츠를 이어붙인다") + func 페이지_로딩중이면_다음페이지를_조회해_컨텐츠를_이어붙인다() async throws { + let store = TestStore( + initialState: CategorySharingFeature.State( + sharedCategory: SharedCategoryResponse.featureCategorySharing_initialResponse.toDomain() + ) + ) { + CategorySharingFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureCategorySharingTestValue() + } + store.exhaustivity = .off + + await store.send(.view(.페이지_로딩중일때)) + await store.receive(\.async.공유받은_카테고리_조회_API) + await store.receive(\.inner.공유받은_카테고리_API_반영) { + $0.domain.sharedCategory = SharedCategoryResponse.featureCategorySharing_nextPageResponse.toDomain() + $0.isLoading = false + guard $0.contents.count == 1 else { + preconditionFailure("이어붙인 컨텐츠가 1개여야 합니다: \($0.contents.count)") + } + guard + $0.contents[0].content.authorNickname == "공유멤버3", + $0.contents[0].content.authorUserId == 93 + else { + preconditionFailure("author 정보가 보존되지 않았습니다") + } + } + } + + // MARK: - TC-14: 저장하기 → 홈 화면에 포킷 카드 추가, 상세 페이지로 이동 + + @Test("TC-14 저장버튼을 누르면 카테고리 정보를 포함한 delegate를 보낸다") + func TC14_저장버튼을_누르면_카테고리_정보를_포함한_delegate를_보낸다() async throws { + let sharedCategory = SharedCategoryResponse.featureCategorySharing_initialResponse.toDomain() + let store = TestStore( + initialState: CategorySharingFeature.State(sharedCategory: sharedCategory) + ) { + CategorySharingFeature() + } + + await store.send(.view(.저장_버튼_눌렀을때)) + await store.receive(\.delegate.공유받은_카테고리_추가) + } + + @Test("TC-14 저장시 delegate에 전달되는 카테고리ID와 이름이 정확하다") + func TC14_저장시_delegate에_전달되는_카테고리ID와_이름이_정확하다() async throws { + let sharedCategory = SharedCategoryResponse.featureCategorySharing_initialResponse.toDomain() + + let store = TestStore( + initialState: CategorySharingFeature.State(sharedCategory: sharedCategory) + ) { + CategorySharingFeature() + } + + await store.send(.view(.저장_버튼_눌렀을때)) + await store.receive(\.delegate.공유받은_카테고리_추가) + } + + // MARK: - TC-15: 초대 수락 → 카드 추가, 공유 포킷 상세로 이동 + + @Test("TC-15 초대수락 저장시 공유 카테고리의 contentCount와 이미지가 보존된다") + func TC15_초대수락_저장시_공유_카테고리_정보가_보존된다() async throws { + let sharedCategory = SharedCategoryResponse.featureCategorySharing_inviteResponse.toDomain() + let store = TestStore( + initialState: CategorySharingFeature.State(sharedCategory: sharedCategory) + ) { + CategorySharingFeature() + } + store.exhaustivity = .off + + // 초대 수락 후 뷰가 나타나면 컨텐츠가 로드된다 + await store.send(.view(.뷰가_나타났을때)) { + $0.isLoading = false + guard $0.contents.count == 1 else { + preconditionFailure("초대 컨텐츠가 1개여야 합니다: \($0.contents.count)") + } + guard + $0.contents[0].content.authorNickname == "초대자", + $0.contents[0].content.authorUserId == 95 + else { + preconditionFailure("초대 author 정보가 보존되지 않았습니다") + } + } + + // 저장 버튼을 누르면 카테고리 정보가 delegate로 전달된다 + await store.send(.view(.저장_버튼_눌렀을때)) + await store.receive(\.delegate.공유받은_카테고리_추가) + } + + // MARK: - TC-17: 마지막 참여자 나가기 → 포킷 삭제 + + @Test("TC-17 마지막 참여자가 dismiss하면 dismiss가 호출된다") + func TC17_마지막_참여자가_dismiss하면_dismiss가_호출된다() async throws { + let sharedCategory = SharedCategoryResponse + .featureCategorySharing_lastParticipantResponse.toDomain() + let store = TestStore( + initialState: CategorySharingFeature.State(sharedCategory: sharedCategory) + ) { + CategorySharingFeature() + } withDependencies: { + $0.dismiss = DismissEffect { } + } + + // 마지막 참여자(컨텐츠 0개)의 상태를 확인 + #expect(sharedCategory.category.contentCount == 0) + #expect(sharedCategory.contentList.data.isEmpty) + + // dismiss 호출 시 정상적으로 실행된다 + await store.send(.view(.dismiss)) } } diff --git a/Projects/Feature/FeatureContentCardDemo/Sources/FeatureContentCardDemoApp.swift b/Projects/Feature/FeatureContentCardDemo/Sources/FeatureContentCardDemoApp.swift index 17fadce2..ad454eba 100644 --- a/Projects/Feature/FeatureContentCardDemo/Sources/FeatureContentCardDemoApp.swift +++ b/Projects/Feature/FeatureContentCardDemo/Sources/FeatureContentCardDemoApp.swift @@ -6,12 +6,15 @@ // import SwiftUI +import XCTestDynamicOverlay @main struct FeatureContentCardDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + } } } } diff --git a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTestSupport.swift b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTestSupport.swift new file mode 100644 index 00000000..915bb988 --- /dev/null +++ b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTestSupport.swift @@ -0,0 +1,164 @@ +import Foundation + +import CoreKit +import Domain +import Util + +private func featureContentCardDecode(_ value: Any) -> T { + let data = try! JSONSerialization.data(withJSONObject: value) + return try! JSONDecoder().decode(T.self, from: data) +} + +extension BaseCategoryResponse { + static let featureContentCard_shared: Self = featureContentCardDecode([ + "categoryId": 77, + "categoryName": "공유 포킷" + ]) +} + +extension BaseContentItem { + static let featureContentCard_authorVisible = Self( + id: 101, + categoryName: "공유 포킷", + categoryId: 77, + title: "작성자 프로필이 있는 링크", + memo: "공유 링크 메모", + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/author-visible", + domain: "pokit.link", + createdAt: "2026.04.06", + isRead: false, + isFavorite: false, + keyword: nil, + authorUserId: 202, + authorNickname: "공유멤버", + authorProfileImageURL: "https://example.com/author-profile.png" + ) + + static let featureContentCard_favorite = Self( + id: 102, + categoryName: "내 포킷", + categoryId: 78, + title: "즐겨찾기 링크", + memo: nil, + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/favorite", + domain: "pokit.link", + createdAt: "2026.04.05", + isRead: false, + isFavorite: true, + keyword: nil, + authorUserId: nil, + authorNickname: nil, + authorProfileImageURL: nil + ) + + static let featureContentCard_authorHidden = Self( + id: 103, + categoryName: "공유 포킷", + categoryId: 77, + title: "작성자 정보가 없는 링크", + memo: "공유 링크 메모", + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/author-hidden", + domain: "pokit.link", + createdAt: "2026.04.04", + isRead: false, + isFavorite: false, + keyword: nil, + authorUserId: nil, + authorNickname: nil, + authorProfileImageURL: nil + ) + + static let featureContentCard_personalized = Self( + id: 104, + categoryName: "공유 포킷", + categoryId: 77, + title: "개인화 상태 링크", + memo: "공유 링크 메모", + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/personalized", + domain: "pokit.link", + createdAt: "2026.04.03", + isRead: true, + isFavorite: true, + keyword: nil, + authorUserId: 204, + authorNickname: "공유멤버2", + authorProfileImageURL: "https://example.com/author-profile-2.png" + ) +} + +extension ContentDetailResponse { + static let featureContentCard_detailResponse = Self( + contentId: 101, + category: .featureContentCard_shared, + data: "https://pokit.link/author-visible", + title: "작성자 프로필이 있는 링크", + memo: "공유 링크 메모", + alertYn: "NO", + createdAt: "2026-04-06T00:00:00Z", + favorites: false, + keyword: nil, + userNickname: "공유멤버", + authorUserId: 202, + authorNickname: "공유멤버", + authorProfileImageURL: "https://example.com/author-profile.png" + ) + + static let featureContentCard_detailWithoutAuthor = Self( + contentId: 103, + category: .featureContentCard_shared, + data: "https://pokit.link/author-hidden", + title: "작성자 정보가 없는 링크", + memo: "공유 링크 메모", + alertYn: "NO", + createdAt: "2026-04-04T00:00:00Z", + favorites: false, + keyword: nil, + userNickname: "알 수 없음", + authorUserId: nil, + authorNickname: nil, + authorProfileImageURL: nil + ) +} + +extension ContentClient { + static func featureContentCardTestValue( + detailResponse: ContentDetailResponse = .featureContentCard_detailResponse, + onFavorite: (@Sendable (String) async throws -> BookmarkResponse)? = nil, + onUnfavorite: (@Sendable (String) async throws -> Void)? = nil, + onThumbnailUpdate: (@Sendable (String, ThumbnailRequest) async throws -> Void)? = nil + ) -> Self { + var client = Self.testValue + client.컨텐츠_상세_조회 = { _ in detailResponse } + client.즐겨찾기 = { contentId in + if let onFavorite { + return try await onFavorite(contentId) + } + return .mock + } + client.즐겨찾기_취소 = { contentId in + if let onUnfavorite { + try await onUnfavorite(contentId) + } + } + client.썸네일_수정 = { contentId, request in + if let onThumbnailUpdate { + try await onThumbnailUpdate(contentId, request) + } + } + return client + } +} + +extension SwiftSoupClient { + static func featureContentCardTestValue( + imageURL: String = "https://example.com/updated-thumbnail.png" + ) -> Self { + var client = Self.testValue + client.parseOGImageURL = { _ in imageURL } + return client + } +} diff --git a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift index d5ea6705..547035d8 100644 --- a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift +++ b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift @@ -1,87 +1,132 @@ import ComposableArchitecture -import XCTest -import Domain import CoreKit +import Domain +import Testing @testable import FeatureContentCard -final class SendTests: XCTestCase { - - func test_primeTest() async { - let count = 10 - var sharedAverage: CFAbsoluteTime = 0.0 - for _ in 0.. String? = { _ in - "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + $0[ContentClient.self] = .featureContentCardTestValue() + $0[SwiftSoupClient.self] = .featureContentCardTestValue() + $0.openURL = .init { _ in true } + } + + await store.send(.view(.컨텐츠_항목_눌렀을때)) + await store.receive(\.async.컨텐츠_상세_조회_API) + await store.receive(\.inner.컨텐츠_상세_조회_API_반영) { + $0.content.isRead = true + guard $0.content.authorProfileImageURL == "https://example.com/author-profile.png" else { + preconditionFailure("작성자 프로필 이미지가 유지되지 않았습니다: \($0.content)") } - - $0[SwiftSoupClient.self].parseOGImageURL = parseOGImageURL } - - let start = CFAbsoluteTimeGetCurrent() + } + + @Test("즐겨찾기상태면 즐겨찾기취소 API를 호출한다") + func 즐겨찾기상태면_즐겨찾기취소_API를_호출한다() async throws { + let store = TestStore(initialState: ContentCardFeature.State( + content: .featureContentCard_favorite + )) { + ContentCardFeature() + } withDependencies: { + $0[ContentClient.self] = .featureContentCardTestValue( + onUnfavorite: { contentId in + guard contentId == "102" else { + preconditionFailure("예상하지 못한 즐겨찾기 취소 요청입니다: \(contentId)") + } + } + ) + } + + await store.send(.view(.즐겨찾기_버튼_눌렀을때)) + await store.receive(\.inner.즐겨찾기_API_반영) { + $0.content.isFavorite = false + } + } + + @Test("작성자정보가 없으면 메타데이터조회후에도 nil을 유지한다") + func 작성자정보가_없으면_메타데이터조회후에도_nil을_유지한다() async throws { + let updatedThumbnail = "https://example.com/updated-thumbnail-hidden.png" + + let store = TestStore(initialState: ContentCardFeature.State( + content: .featureContentCard_authorHidden + )) { + ContentCardFeature() + } withDependencies: { + $0[ContentClient.self] = .featureContentCardTestValue( + detailResponse: .featureContentCard_detailWithoutAuthor + ) + $0[SwiftSoupClient.self] = .featureContentCardTestValue(imageURL: updatedThumbnail) + } + await store.send(.view(.메타데이터_조회)) await store.receive(\.inner.메타데이터_조회_수행_반영) { - $0.content.thumbNail = "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + $0.content.thumbNail = updatedThumbnail + guard + $0.content.authorUserId == nil, + $0.content.authorNickname == nil, + $0.content.authorProfileImageURL == nil + else { + preconditionFailure("작성자 정보가 없는 링크의 author 값이 변경되었습니다: \($0.content)") + } } - let end = CFAbsoluteTimeGetCurrent() - average += end - start } - - @MainActor - func test_shared_메서드_미적용(_ average: inout CFAbsoluteTime) async { - let store = TestStore(initialState: LegacyContentCardFeature.State( - content: ContentBaseResponse.mock(id: 0).toDomain() + + @Test("공유포킷 개인별 안읽음과 즐겨찾기상태를 유지한다") + func 공유포킷_개인별_안읽음과_즐겨찾기상태를_유지한다() async throws { + let updatedThumbnail = "https://example.com/personalized-thumbnail.png" + + let store = TestStore(initialState: ContentCardFeature.State( + content: .featureContentCard_personalized )) { - LegacyContentCardFeature()._printChanges(.actionLabels) + ContentCardFeature() } withDependencies: { - $0[ContentClient.self] = .testValue - let parseOGImageURL: @Sendable ( - _ url: URL - ) async throws -> String? = { _ in - "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" - } - - $0[SwiftSoupClient.self].parseOGImageURL = parseOGImageURL + $0[ContentClient.self] = .featureContentCardTestValue() + $0[SwiftSoupClient.self] = .featureContentCardTestValue(imageURL: updatedThumbnail) } - - let start = CFAbsoluteTimeGetCurrent() + await store.send(.view(.메타데이터_조회)) - await store.receive(\.async.메타데이터_조회_수행) await store.receive(\.inner.메타데이터_조회_수행_반영) { - $0.content.thumbNail = "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + $0.content.thumbNail = updatedThumbnail + guard + $0.content.isRead == true, + $0.content.isFavorite == true + else { + preconditionFailure("개인별 안읽음/즐겨찾기 상태가 유지되지 않았습니다: \($0.content)") + } } - await store.receive(\.async.썸네일_수정_API) - let end = CFAbsoluteTimeGetCurrent() - average += end - start } } - - diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift index af6ef08e..a66fd056 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift @@ -21,17 +21,23 @@ public struct ContentDetailFeature { private var swiftSoup @Dependency(ContentClient.self) private var contentClient + @Dependency(CategoryClient.self) + private var categoryClient + @Dependency(UserDefaultsClient.self) + private var userDefaults /// - State @ObservableState public struct State: Equatable { public init( content: BaseContentDetail? = nil, - contentId: Int? = nil + contentId: Int? = nil, + authorUserId: Int? = nil ) { self.domain = .init( content: content, contentId: contentId ) + self.listAuthorUserId = authorUserId } fileprivate var domain: ContentDetail var content: BaseContentDetail? { @@ -45,11 +51,27 @@ public struct ContentDetailFeature { var linkImageURL: String? = nil var showAlert: Bool = false var showShareSheet: Bool = false + var showReportSheet: Bool = false + var shouldPresentReportSheetAfterReasonFetch: Bool = false + var showSelectSheet: Bool = false var memoTextAreaState: PokitInputStyle.State = .memo(isReadOnly: true) var linkPopup: PokitLinkPopup.PopupType? + var currentUserId: Int? + var pokitList: [BaseCategoryItem]? + var selectedPokit: BaseCategoryItem? + var reportReasons: [BaseReportReason] = [] + /// 목록 API에서 전달받은 authorUserId (상세 API에 없을 때 fallback) + var listAuthorUserId: Int? + var isMine: Bool { + guard let currentUserId else { return true } + let authorUserId = domain.content?.authorUserId ?? listAuthorUserId + guard let authorUserId else { return true } + return currentUserId == authorUserId + } } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -69,6 +91,12 @@ public struct ContentDetailFeature { case 삭제_버튼_눌렀을때 case 삭제확인_버튼_눌렀을때 case 즐겨찾기_버튼_눌렀을때 + case 내포킷에_저장하기_버튼_눌렀을때 + case 신고하기_버튼_눌렀을때 + case 신고하기_확인_버튼_눌렀을때(String) + case 신고시트_해제 + case 포킷선택_항목_눌렀을때(BaseCategoryItem) + case 포킷_추가하기_버튼_눌렀을때 case 키보드_취소_버튼_눌렀을때 case 키보드_완료_버튼_눌렀울때 @@ -77,27 +105,38 @@ public struct ContentDetailFeature { case 링크_공유_완료되었을때 } + @CasePathable public enum InnerAction: Equatable { case 컨텐츠_상세_조회_API_반영(content: BaseContentDetail) case 즐겨찾기_API_반영(Bool) + case 컨텐츠_신고사유_조회_API_반영([BaseReportReason]) + case 카테고리_목록_조회_API_반영(BaseCategoryListInquiry) case 링크팝업_활성화(PokitLinkPopup.PopupType) } + @CasePathable public enum AsyncAction: Equatable { case 컨텐츠_상세_조회_API(id: Int) case 즐겨찾기_API(id: Int) case 즐겨찾기_취소_API(id: Int) case 컨텐츠_삭제_API(id: Int) + case 컨텐츠_신고사유_조회_API + case 컨텐츠_신고_API(id: Int, reportReason: String) + case 카테고리_목록_조회_API + case 컨텐츠_추가_API(categoryId: Int) case 컨텐츠_수정_API } + @CasePathable public enum ScopeAction: Equatable { case 없음 } + @CasePathable public enum DelegateAction: Equatable { case editButtonTapped(contentId: Int) case 즐겨찾기_갱신_완료 case 컨텐츠_조회_완료 case 컨텐츠_삭제_완료 + case 포킷_추가하기_버튼_눌렀을때 } } @@ -141,13 +180,17 @@ private extension ContentDetailFeature { func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { case .뷰가_나타났을때: - /// - 나중에 공유 받은 컨텐츠인지 확인해야함 - state.memoTextAreaState = .memo(isReadOnly: false) + if let userIdString = userDefaults.stringKey(.userId), + let userId = Int(userIdString) { + state.currentUserId = userId + } + syncMemoState(for: &state) if let id = state.domain.contentId { return .send(.async(.컨텐츠_상세_조회_API(id: id))) } if let content = state.domain.content { state.memo = content.memo + syncMemoState(for: &state) return .none } return .none @@ -155,9 +198,11 @@ private extension ContentDetailFeature { state.showShareSheet = true return .none case .수정_버튼_눌렀을때: + guard state.isMine else { return .none } guard let content = state.domain.content else { return .none } return .send(.delegate(.editButtonTapped(contentId: content.id))) case .삭제_버튼_눌렀을때: + guard state.isMine else { return .none } state.showAlert = true return .none case .삭제확인_버튼_눌렀을때: @@ -175,6 +220,32 @@ private extension ContentDetailFeature { return favorites ? .send(.async(.즐겨찾기_취소_API(id: content.id))) : .send(.async(.즐겨찾기_API(id: content.id))) + case .내포킷에_저장하기_버튼_눌렀을때: + state.showSelectSheet = true + return .send(.async(.카테고리_목록_조회_API)) + case .신고하기_버튼_눌렀을때: + guard !state.reportReasons.isEmpty else { + state.shouldPresentReportSheetAfterReasonFetch = true + return .send(.async(.컨텐츠_신고사유_조회_API)) + } + state.showReportSheet = true + return .none + case let .신고하기_확인_버튼_눌렀을때(reportReason): + guard let content = state.domain.content else { return .none } + state.showReportSheet = false + state.shouldPresentReportSheetAfterReasonFetch = false + return .send(.async(.컨텐츠_신고_API(id: content.id, reportReason: reportReason))) + case .신고시트_해제: + state.showReportSheet = false + state.shouldPresentReportSheetAfterReasonFetch = false + return .none + case let .포킷선택_항목_눌렀을때(pokit): + state.selectedPokit = pokit + state.showSelectSheet = false + return .send(.async(.컨텐츠_추가_API(categoryId: pokit.id))) + case .포킷_추가하기_버튼_눌렀을때: + state.showSelectSheet = false + return .send(.delegate(.포킷_추가하기_버튼_눌렀을때)) case .링크_공유_완료되었을때: state.showShareSheet = false return .none @@ -182,9 +253,11 @@ private extension ContentDetailFeature { state.showAlert = false return .none case .키보드_취소_버튼_눌렀을때: + guard state.isMine else { return .none } state.memo = state.domain.content?.memo ?? "" return .none case .키보드_완료_버튼_눌렀울때: + guard state.isMine else { return .none } let memo = state.memo guard memo != state.domain.content?.memo else { return .none } state.domain.content?.memo = memo @@ -198,10 +271,37 @@ private extension ContentDetailFeature { case .컨텐츠_상세_조회_API_반영(content: let content): state.domain.content = content state.memo = state.domain.content?.memo ?? "" + syncMemoState(for: &state) return .send(.delegate(.컨텐츠_조회_완료)) case .즐겨찾기_API_반영(let favorite): state.domain.content?.favorites = favorite return .send(.delegate(.즐겨찾기_갱신_완료)) + case let .컨텐츠_신고사유_조회_API_반영(reasons): + state.reportReasons = reasons + if state.shouldPresentReportSheetAfterReasonFetch { + state.showReportSheet = true + state.shouldPresentReportSheetAfterReasonFetch = false + } + return .none + case let .카테고리_목록_조회_API_반영(categoryList): + guard + let unclassifiedItemIdx = categoryList.data?.firstIndex(where: { + $0.categoryName == Constants.미분류 + }), + let unclassifiedItem = categoryList.data?.first(where: { + $0.categoryName == Constants.미분류 + }) + else { + state.pokitList = categoryList.data + return .none + } + + var list = categoryList + list.data?.remove(at: unclassifiedItemIdx) + list.data?.insert(unclassifiedItem, at: 0) + state.pokitList = list.data + state.selectedPokit = unclassifiedItem + return .none case let .링크팝업_활성화(type): state.linkPopup = type return .none @@ -235,6 +335,48 @@ private extension ContentDetailFeature { await send(.delegate(.컨텐츠_삭제_완료)) await dismiss() } + case .컨텐츠_신고사유_조회_API: + return .run { send in + let reasons = try await contentClient.컨텐츠_신고사유_조회().toDomain() + await send(.inner(.컨텐츠_신고사유_조회_API_반영(reasons))) + } + case let .컨텐츠_신고_API(id, reportReason): + return .run { send in + let request = ContentReportRequest(reportReason: reportReason) + try await contentClient.컨텐츠_신고_사유(id, request) + await send( + .inner(.링크팝업_활성화(.report(title: "신고가 완료되었습니다"))), + animation: .pokitSpring + ) + } + case .카테고리_목록_조회_API: + return .run { send in + let request = BasePageableRequest(page: 0, size: 30, sort: ["createdAt,desc"]) + let categoryList = try await categoryClient.카테고리_목록_조회( + request, + false, + true + ).toDomain() + await send(.inner(.카테고리_목록_조회_API_반영(categoryList))) + } + case let .컨텐츠_추가_API(categoryId): + guard let content = state.domain.content else { return .none } + return .run { [swiftSoup] send in + let imageURL = try? await swiftSoup.parseOGImageURL(URL(string: content.data)!) + let request = ContentBaseRequest( + data: content.data, + title: content.title, + categoryId: categoryId, + memo: content.memo, + alertYn: "NO", + thumbNail: imageURL + ) + let _ = try await contentClient.컨텐츠_추가(request) + await send( + .inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구, until: 4))), + animation: .pokitSpring + ) + } case .컨텐츠_수정_API: guard let content = state.domain.content, @@ -278,4 +420,8 @@ private extension ContentDetailFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { return .none } + + func syncMemoState(for state: inout State) { + state.memoTextAreaState = .memo(isReadOnly: !state.isMine) + } } diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift index 425f7a56..0accb389 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift @@ -76,6 +76,31 @@ public extension ContentDetailView { .presentationDetents([.medium, .large]) } } + .sheet(isPresented: $store.showReportSheet) { + let reasons = store.reportReasons.map { + PokitReportBottomSheet.Item( + id: $0.code, + title: $0.description + ) + } + + PokitReportBottomSheet( + reasons: reasons, + onConfirm: { send(.신고하기_확인_버튼_눌렀을때($0.id)) } + ) + } + .sheet(isPresented: $store.showSelectSheet) { + PokitSelectSheet( + list: store.pokitList, + selectedItem: .constant(nil), + itemSelected: { send(.포킷선택_항목_눌렀을때($0)) }, + pokitAddAction: { send(.포킷_추가하기_버튼_눌렀을때) } + ) + .presentationDragIndicator(.visible) + .pokitPresentationCornerRadius() + .presentationDetents([.height(564)]) + .pokitPresentationBackground() + } .task { await send(.뷰가_나타났을때, animation: .pokitDissolve).finish() } @@ -105,6 +130,22 @@ private extension ContentDetailView { } } + @ViewBuilder + func authorInfo(content: BaseContentDetail) -> some View { + if let authorNickname = content.authorNickname, !store.isMine { + HStack(spacing: 4) { + Image(.icon(.member)) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(.pokit(.icon(.tertiary))) + + Text(authorNickname) + .pokitFont(.detail2) + .foregroundStyle(.pokit(.text(.tertiary))) + } + } + } + @ViewBuilder func title(content: BaseContentDetail) -> some View { VStack(alignment: .leading, spacing: 8) { @@ -118,7 +159,7 @@ private extension ContentDetailView { HStack { remindAndBadge(content: content) - Spacer() + authorInfo(content: content) Text(content.createdAt) .pokitFont(.detail2) @@ -207,25 +248,46 @@ private extension ContentDetailView { ), action: { send(.공유_버튼_눌렀을때) } ) - - PokitListButton( - title: "수정하기", - type: .bottomSheet( - icon: .icon(.edit), - iconColor: .pokit(.icon(.primary)) - ), - action: { send(.수정_버튼_눌렀을때) } - ) - - PokitListButton( - title: "삭제하기", - type: .bottomSheet( - icon: .icon(.trash), - iconColor: .pokit(.icon(.primary)), - isLast: true - ), - action: { send(.삭제_버튼_눌렀을때) } - ) + + if store.isMine { + PokitListButton( + title: "수정하기", + type: .bottomSheet( + icon: .icon(.edit), + iconColor: .pokit(.icon(.primary)) + ), + action: { send(.수정_버튼_눌렀을때) } + ) + + PokitListButton( + title: "삭제하기", + type: .bottomSheet( + icon: .icon(.trash), + iconColor: .pokit(.icon(.primary)), + isLast: true + ), + action: { send(.삭제_버튼_눌렀을때) } + ) + } else { + PokitListButton( + title: "내 포킷에 저장하기", + type: .bottomSheet( + icon: .icon(.savePokit), + iconColor: .pokit(.icon(.primary)) + ), + action: { send(.내포킷에_저장하기_버튼_눌렀을때) } + ) + + PokitListButton( + title: "신고하기", + type: .bottomSheet( + icon: .icon(.report), + iconColor: .pokit(.icon(.primary)), + isLast: true + ), + action: { send(.신고하기_버튼_눌렀을때) } + ) + } } } } @@ -240,5 +302,3 @@ private extension ContentDetailView { ) ) } - - diff --git a/Projects/Feature/FeatureContentDetailDemo/Sources/FeatureContentDetailDemoApp.swift b/Projects/Feature/FeatureContentDetailDemo/Sources/FeatureContentDetailDemoApp.swift index d2cb27a3..c0d86d40 100644 --- a/Projects/Feature/FeatureContentDetailDemo/Sources/FeatureContentDetailDemoApp.swift +++ b/Projects/Feature/FeatureContentDetailDemo/Sources/FeatureContentDetailDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import FeatureContentDetail @@ -15,22 +16,24 @@ struct FeatureContentDetailDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 - VStack { - Spacer() - - Button("링크 상세") { - showLinkDetail = true + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + VStack { + Spacer() + + Button("링크 상세") { + showLinkDetail = true + } + + Spacer() + } + .background(.white) + .sheet(isPresented: $showLinkDetail) { + ContentDetailView(store: .init( + initialState: .init(contentId: 3), + reducer: { ContentDetailFeature()._printChanges() }) + ) } - - Spacer() - } - .background(.white) - .sheet(isPresented: $showLinkDetail) { - ContentDetailView(store: .init( - initialState: .init(contentId: 3), - reducer: { ContentDetailFeature()._printChanges() }) - ) } } } diff --git a/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTestSupport.swift b/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTestSupport.swift new file mode 100644 index 00000000..ef495b5f --- /dev/null +++ b/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTestSupport.swift @@ -0,0 +1,263 @@ +import Foundation + +import CoreKit +import Domain +import Util + +private func featureContentDetailDecode(_ value: Any) -> T { + let data = try! JSONSerialization.data(withJSONObject: value) + return try! JSONDecoder().decode(T.self, from: data) +} + +extension BaseCategoryResponse { + static let featureContentDetail_pokit: Self = featureContentDetailDecode([ + "categoryId": 12, + "categoryName": "개발 레퍼런스" + ]) +} + +extension BaseCategoryInfo { + static let featureContentDetail_pokit = Self( + categoryId: 12, + categoryName: "개발 레퍼런스" + ) +} + +extension BaseCategoryItem { + static let featureContentDetail_unclassified = Self( + id: 0, + userId: 100, + categoryName: Constants.미분류, + categoryImage: .init(imageId: 10, imageURL: Constants.mockImageUrl), + contentCount: 2, + createdAt: "2026.04.06", + openType: .비공개, + keywordType: .default, + userCount: 1, + isFavorite: false + ) + + static let featureContentDetail_pokit = Self( + id: 12, + userId: 100, + categoryName: "개발 레퍼런스", + categoryImage: .init(imageId: 11, imageURL: Constants.mockImageUrl), + contentCount: 4, + createdAt: "2026.04.06", + openType: .공개, + keywordType: .IT, + userCount: 2, + isFavorite: false + ) +} + +extension BaseContentDetail { + static let featureContentDetail_owned = Self( + id: 301, + category: .featureContentDetail_pokit, + title: "내가 저장한 링크", + data: "https://pokit.link/mine", + memo: "내 메모", + createdAt: "2026.04.06", + favorites: false, + alertYn: .no, + authorUserId: 100, + authorNickname: "나", + authorProfileImageURL: "https://example.com/me.png" + ) + + static let featureContentDetail_shared = Self( + id: 302, + category: .featureContentDetail_pokit, + title: "다른 사람이 저장한 링크", + data: "https://pokit.link/shared", + memo: "공유 메모", + createdAt: "2026.04.06", + favorites: false, + alertYn: .no, + authorUserId: 999, + authorNickname: "공유멤버", + authorProfileImageURL: "https://example.com/shared.png" + ) +} + +extension BaseReportReason { + static let featureContentDetail_spam = Self(code: "SPAM", description: "스팸 또는 혼돈을 야기하는 링크") + static let featureContentDetail_harmful = Self(code: "HARMFUL", description: "유해하거나 위험한 링크") +} + +extension CategoryListInquiryResponse { + static let featureContentDetail_categoryListResponse: Self = featureContentDetailDecode([ + "data": [ + [ + "categoryId": 12, + "userId": 100, + "categoryName": "개발 레퍼런스", + "categoryImage": [ + "imageId": 11, + "imageUrl": Constants.mockImageUrl + ], + "contentCount": 4, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "IT", + "userCount": 2, + "isFavorite": false, + "alertEnabled": true + ], + [ + "categoryId": 0, + "userId": 100, + "categoryName": Constants.미분류, + "categoryImage": [ + "imageId": 10, + "imageUrl": Constants.mockImageUrl + ], + "contentCount": 2, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "default", + "userCount": 1, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 30, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension Array where Element == BaseCategoryItem { + static let featureContentDetail_sortedPokits: Self = { + var list = CategoryListInquiryResponse.featureContentDetail_categoryListResponse.toDomain().data ?? [] + guard let unclassifiedIndex = list.firstIndex(where: { $0.categoryName == Constants.미분류 }) else { + return list + } + let unclassifiedItem = list.remove(at: unclassifiedIndex) + list.insert(unclassifiedItem, at: 0) + return list + }() +} + +extension ContentDetailResponse { + static let featureContentDetail_ownedResponse = Self( + contentId: 301, + category: .featureContentDetail_pokit, + data: "https://pokit.link/mine", + title: "내가 저장한 링크", + memo: "내 메모", + alertYn: "NO", + createdAt: "2026-04-06T00:00:00Z", + favorites: false, + keyword: nil, + userNickname: "나", + authorUserId: 100, + authorNickname: "나", + authorProfileImageURL: "https://example.com/me.png" + ) + + static let featureContentDetail_sharedResponse = Self( + contentId: 302, + category: .featureContentDetail_pokit, + data: "https://pokit.link/shared", + title: "다른 사람이 저장한 링크", + memo: "공유 메모", + alertYn: "NO", + createdAt: "2026-04-06T00:00:00Z", + favorites: false, + keyword: nil, + userNickname: "공유멤버", + authorUserId: 999, + authorNickname: "공유멤버", + authorProfileImageURL: "https://example.com/shared.png" + ) +} + +extension ReportReasonResponse { + static let featureContentDetail_spam: Self = featureContentDetailDecode([ + "code": "SPAM", + "description": "스팸 또는 혼돈을 야기하는 링크" + ]) + static let featureContentDetail_harmful: Self = featureContentDetailDecode([ + "code": "HARMFUL", + "description": "유해하거나 위험한 링크" + ]) +} + +extension ContentClient { + static func featureContentDetailTestValue( + detailResponse: ContentDetailResponse = .featureContentDetail_sharedResponse, + reportReasons: [ReportReasonResponse] = [ + .featureContentDetail_spam, + .featureContentDetail_harmful + ], + onReport: (@Sendable (Int, ContentReportRequest) async throws -> Void)? = nil, + onAdd: (@Sendable (ContentBaseRequest) async throws -> ContentDetailResponse)? = nil, + onEdit: (@Sendable (String, ContentBaseRequest) async throws -> ContentDetailResponse)? = nil + ) -> Self { + var client = Self.testValue + client.컨텐츠_상세_조회 = { _ in detailResponse } + client.컨텐츠_신고사유_조회 = { reportReasons } + client.컨텐츠_신고_사유 = { id, request in + if let onReport { + try await onReport(id, request) + } + } + client.컨텐츠_추가 = { request in + if let onAdd { + return try await onAdd(request) + } + return .featureContentDetail_sharedResponse + } + client.컨텐츠_수정 = { contentId, request in + if let onEdit { + return try await onEdit(contentId, request) + } + return .featureContentDetail_ownedResponse + } + return client + } +} + +extension CategoryClient { + static func featureContentDetailTestValue( + categoryListResponse: CategoryListInquiryResponse = .featureContentDetail_categoryListResponse + ) -> Self { + var client = Self.testValue + client.카테고리_목록_조회 = { _, _, _ in categoryListResponse } + return client + } +} + +extension UserDefaultsClient { + static func featureContentDetailTestValue(currentUserId: Int?) -> Self { + var client = Self.testValue + client.stringKey = { key in + switch key { + case .userId: + return currentUserId.map(String.init) + default: + return nil + } + } + return client + } +} + +extension SwiftSoupClient { + static func featureContentDetailTestValue( + imageURL: String = "https://example.com/saved-thumbnail.png" + ) -> Self { + var client = Self.testValue + client.parseOGImageURL = { _ in imageURL } + return client + } +} diff --git a/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTests.swift b/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTests.swift index 96dfa6dd..cb7303c1 100644 --- a/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTests.swift +++ b/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTests.swift @@ -1,10 +1,341 @@ +import Foundation + import ComposableArchitecture -import XCTest +import CoreKit +import Domain +import Util +import Testing @testable import FeatureContentDetail -final class FeatureContentDetailTests: XCTestCase { - func test() { - +@MainActor +struct FeatureContentDetailTests { + @Test("뷰가 나타났을 때 내 링크면 수정 가능 상태가 된다") + func 뷰가_나타났을때_내링크면_수정가능상태가된다() async throws { + let store = TestStore(initialState: ContentDetailFeature.State( + content: .featureContentDetail_owned + )) { + ContentDetailFeature() + } withDependencies: { + $0[UserDefaultsClient.self] = .featureContentDetailTestValue(currentUserId: 100) + } + + await store.send(.view(.뷰가_나타났을때)) { + $0.memo = "내 메모" + $0.currentUserId = 100 + $0.memoTextAreaState = .memo(isReadOnly: false) + } + } + + @Test("뷰가 나타났을 때 타인 링크면 읽기 전용 상태가 된다") + func 뷰가_나타났을때_타인링크면_읽기전용상태가된다() async throws { + let store = TestStore(initialState: ContentDetailFeature.State( + content: .featureContentDetail_shared + )) { + ContentDetailFeature() + } withDependencies: { + $0[UserDefaultsClient.self] = .featureContentDetailTestValue(currentUserId: 100) + } + + await store.send(.view(.뷰가_나타났을때)) { + $0.memo = "공유 메모" + $0.currentUserId = 100 + $0.memoTextAreaState = .memo(isReadOnly: true) + } + } + + @Test("신고하기 첫 진입시 신고 사유를 조회한 뒤 시트를 노출한다") + func 신고하기_첫진입시_신고사유를_조회한뒤_시트를_노출한다() async throws { + let store = TestStore(initialState: ContentDetailFeature.State( + content: .featureContentDetail_shared + )) { + ContentDetailFeature() + } withDependencies: { + $0[ContentClient.self] = .featureContentDetailTestValue() + $0[UserDefaultsClient.self] = .featureContentDetailTestValue(currentUserId: 100) + } + + await store.send(.view(.뷰가_나타났을때)) { + $0.memo = "공유 메모" + $0.currentUserId = 100 + $0.memoTextAreaState = .memo(isReadOnly: true) + } + await store.send(.view(.신고하기_버튼_눌렀을때)) { + $0.shouldPresentReportSheetAfterReasonFetch = true + } + await store.receive(\.async.컨텐츠_신고사유_조회_API) + await store.receive(\.inner.컨텐츠_신고사유_조회_API_반영) { + $0.reportReasons = [ + .featureContentDetail_spam, + .featureContentDetail_harmful + ] + $0.showReportSheet = true + $0.shouldPresentReportSheetAfterReasonFetch = false + } + } + + @Test("신고하기 확인시 신고 API를 호출하고 완료 팝업을 띄운다") + func 신고하기_확인시_신고API를_호출하고_완료팝업을_띄운다() async throws { + var initialState = ContentDetailFeature.State(content: .featureContentDetail_shared) + initialState.showReportSheet = true + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } withDependencies: { + $0[ContentClient.self] = .featureContentDetailTestValue( + onReport: { id, request in + guard id == 302, request.reportReason == "SPAM" else { + preconditionFailure("예상하지 못한 신고 요청입니다: \(id), \(request.reportReason)") + } + } + ) + } + + await store.send(.view(.신고하기_확인_버튼_눌렀을때("SPAM"))) { + $0.showReportSheet = false + $0.shouldPresentReportSheetAfterReasonFetch = false + } + await store.receive(\.async.컨텐츠_신고_API) + await store.receive(\.inner.링크팝업_활성화) { + $0.linkPopup = .report(title: "신고가 완료되었습니다") + } + } + + @Test("신고 사유가 이미 있으면 즉시 시트를 연다") + func 신고사유가_이미있으면_즉시_시트를_연다() async throws { + var initialState = ContentDetailFeature.State(content: .featureContentDetail_shared) + initialState.reportReasons = [ + .featureContentDetail_spam, + .featureContentDetail_harmful + ] + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } + + await store.send(.view(.신고하기_버튼_눌렀을때)) { + $0.showReportSheet = true + } + } + + @Test("신고 시트를 닫으면 관련 상태를 정리한다") + func 신고시트를_닫으면_관련상태를_정리한다() async throws { + var initialState = ContentDetailFeature.State(content: .featureContentDetail_shared) + initialState.showReportSheet = true + initialState.shouldPresentReportSheetAfterReasonFetch = true + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } + + await store.send(.view(.신고시트_해제)) { + $0.showReportSheet = false + $0.shouldPresentReportSheetAfterReasonFetch = false + } + } + + @Test("내 포킷에 저장하기를 누르면 미분류가 맨 앞으로 재배치된다") + func 내포킷에_저장하기를_누르면_미분류가_맨앞으로_재배치된다() async throws { + let store = TestStore(initialState: ContentDetailFeature.State( + content: .featureContentDetail_shared + )) { + ContentDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureContentDetailTestValue() + } + + await store.send(.view(.내포킷에_저장하기_버튼_눌렀을때)) { + $0.showSelectSheet = true + } + await store.receive(\.async.카테고리_목록_조회_API) + await store.receive(\.inner.카테고리_목록_조회_API_반영) { + $0.pokitList = [BaseCategoryItem].featureContentDetail_sortedPokits + $0.selectedPokit = [BaseCategoryItem].featureContentDetail_sortedPokits.first + } + } + + @Test("TC-18: 내 링크일 때 수정 버튼을 누르면 delegate를 보낸다") + func 내링크일때_수정버튼을_누르면_delegate를_보낸다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_owned + ) + initialState.currentUserId = 100 + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } + + await store.send(.view(.수정_버튼_눌렀을때)) + await store.receive(\.delegate.editButtonTapped) + } + + @Test("TC-18: 내 링크일 때 삭제 버튼을 누르면 경고시트를 노출한다") + func 내링크일때_삭제버튼을_누르면_경고시트를_노출한다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_owned + ) + initialState.currentUserId = 100 + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } + + await store.send(.view(.삭제_버튼_눌렀을때)) { + $0.showAlert = true + } + } + + @Test("TC-19: 타유저 링크일 때 수정 버튼은 무시된다") + func 타유저링크일때_수정버튼은_무시된다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_shared + ) + initialState.currentUserId = 100 + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } + + await store.send(.view(.수정_버튼_눌렀을때)) + // isMine == false이므로 아무 effect도 발생하지 않는다 + } + + @Test("TC-19: 타유저 링크일 때 삭제 버튼은 무시된다") + func 타유저링크일때_삭제버튼은_무시된다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_shared + ) + initialState.currentUserId = 100 + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } + + await store.send(.view(.삭제_버튼_눌렀을때)) + // isMine == false이므로 아무 effect도 발생하지 않는다 + } + + @Test("TC-19: 타유저 링크일 때 내포킷에 저장하기가 가능하다") + func 타유저링크일때_내포킷에_저장하기가_가능하다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_shared + ) + initialState.currentUserId = 100 + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureContentDetailTestValue() + } + + await store.send(.view(.내포킷에_저장하기_버튼_눌렀을때)) { + $0.showSelectSheet = true + } + await store.receive(\.async.카테고리_목록_조회_API) + await store.receive(\.inner.카테고리_목록_조회_API_반영) { + $0.pokitList = [BaseCategoryItem].featureContentDetail_sortedPokits + $0.selectedPokit = [BaseCategoryItem].featureContentDetail_sortedPokits.first + } + } + + @Test("TC-19: 타유저 링크일 때 신고하기가 가능하다") + func 타유저링크일때_신고하기가_가능하다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_shared + ) + initialState.currentUserId = 100 + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } withDependencies: { + $0[ContentClient.self] = .featureContentDetailTestValue() + } + + await store.send(.view(.신고하기_버튼_눌렀을때)) { + $0.shouldPresentReportSheetAfterReasonFetch = true + } + await store.receive(\.async.컨텐츠_신고사유_조회_API) + await store.receive(\.inner.컨텐츠_신고사유_조회_API_반영) { + $0.reportReasons = [ + .featureContentDetail_spam, + .featureContentDetail_harmful + ] + $0.showReportSheet = true + $0.shouldPresentReportSheetAfterReasonFetch = false + } + } + + @Test("TC-18: 내 링크일 때 메모 키보드 완료시 수정 API를 호출한다") + func 내링크일때_메모키보드완료시_수정API를_호출한다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_owned + ) + initialState.currentUserId = 100 + initialState.memo = "수정된 메모" + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } withDependencies: { + $0[ContentClient.self] = .featureContentDetailTestValue( + onEdit: { contentId, request in + guard contentId == "301" else { + preconditionFailure("예상하지 못한 수정 요청입니다: \(contentId)") + } + return .featureContentDetail_ownedResponse + } + ) + $0[SwiftSoupClient.self] = .featureContentDetailTestValue() + } + + store.exhaustivity = .off + await store.send(.view(.키보드_완료_버튼_눌렀울때)) + await store.receive(\.async.컨텐츠_수정_API) + await store.receive(\.inner.링크팝업_활성화) { + $0.linkPopup = .success(title: Constants.메모_수정_완료_문구) + } + } + + @Test("TC-19: 타유저 링크일 때 메모 키보드 완료는 무시된다") + func 타유저링크일때_메모키보드완료는_무시된다() async throws { + var initialState = ContentDetailFeature.State( + content: .featureContentDetail_shared + ) + initialState.currentUserId = 100 + initialState.memo = "타인이 수정 시도" + + let store = TestStore(initialState: initialState) { + ContentDetailFeature() + } + + await store.send(.view(.키보드_완료_버튼_눌렀울때)) + // isMine == false이므로 아무 effect도 발생하지 않는다 + } + + @Test("포킷 선택시 컨텐츠를 추가하고 성공 팝업을 띄운다") + func 포킷선택시_컨텐츠를_추가하고_성공팝업을_띄운다() async throws { + let store = TestStore(initialState: ContentDetailFeature.State( + content: .featureContentDetail_shared + )) { + ContentDetailFeature() + } withDependencies: { + $0[ContentClient.self] = .featureContentDetailTestValue( + onAdd: { request in + guard request.categoryId == 12 else { + preconditionFailure("예상하지 못한 포킷 추가 요청입니다: \(request.categoryId)") + } + return .featureContentDetail_sharedResponse + } + ) + $0[SwiftSoupClient.self] = .featureContentDetailTestValue() + } + + await store.send(.view(.포킷선택_항목_눌렀을때(.featureContentDetail_pokit))) { + $0.selectedPokit = .featureContentDetail_pokit + $0.showSelectSheet = false + } + await store.receive(\.async.컨텐츠_추가_API) + await store.receive(\.inner.링크팝업_활성화) { + $0.linkPopup = .success(title: Constants.링크_저장_완료_문구, until: 4) + } } } diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift index 5afb5cdf..b8060bd1 100644 --- a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift +++ b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift @@ -46,6 +46,7 @@ public struct ContentListFeature { } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -67,6 +68,7 @@ public struct ContentListFeature { case 뷰가_나타났을때 } + @CasePathable public enum InnerAction: Equatable { case 컨텐츠_목록_조회_API_반영(BaseContentListInquiry) case 컨텐츠_목록_조회_페이징_API_반영(BaseContentListInquiry) @@ -74,6 +76,7 @@ public struct ContentListFeature { case 컨텐츠_개수_업데이트(Int) } + @CasePathable public enum AsyncAction: Equatable { case 컨텐츠_목록_조회_페이징_API case 컨텐츠_목록_조회_API @@ -81,10 +84,12 @@ public struct ContentListFeature { case 클립보드_감지 } + @CasePathable public enum ScopeAction { case contents(IdentifiedActionOf) } + @CasePathable public enum DelegateAction: Equatable { case 링크상세(content: BaseContentItem) case 링크수정(contentId: Int) diff --git a/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift b/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift index 06fc4f72..e07caa7c 100644 --- a/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift +++ b/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import FeatureContentList import FeatureIntro @@ -14,16 +15,18 @@ import FeatureIntro struct FeatureContentListDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 - - DemoView(store: .init( - initialState: .init(), - reducer: { DemoFeature() } - )) { - ContentListView(store: .init( - initialState: .init(contentType: .favorite), - reducer: { ContentListFeature() } - )) + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + + DemoView(store: .init( + initialState: .init(), + reducer: { DemoFeature() } + )) { + ContentListView(store: .init( + initialState: .init(contentType: .favorite), + reducer: { ContentListFeature() } + )) + } } } } diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift index 7953d197..e12a800d 100644 --- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift +++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift @@ -80,6 +80,7 @@ public struct ContentSettingFeature { } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -103,6 +104,7 @@ public struct ContentSettingFeature { case 뒤로가기_버튼_눌렀을때 } + @CasePathable public enum InnerAction { case linkPopup(URL?) case linkPreview @@ -119,6 +121,7 @@ public struct ContentSettingFeature { case 선택_카테고리_반영(BaseCategoryItem?) } + @CasePathable public enum AsyncAction: Equatable { case 컨텐츠_상세_조회_API(id: Int) case 카테고리_목록_조회_API @@ -128,8 +131,10 @@ public struct ContentSettingFeature { case 키보드_감지 } + @CasePathable public enum ScopeAction: Equatable { case 없음 } + @CasePathable public enum DelegateAction: Equatable { case 저장하기_완료(category: BaseCategoryItem) case 포킷추가하기 diff --git a/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift b/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift index edaa2abb..6299074d 100644 --- a/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift +++ b/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import FeatureContentSetting import FeatureIntro @@ -15,17 +16,19 @@ struct FeatureContentSettingDemoApp: App { var body: some Scene { WindowGroup { - DemoView(store: .init( - initialState: DemoFeature.State(), - reducer: { DemoFeature() } - )) { - NavigationStack { - ContentSettingView( - store: .init( - initialState: ContentSettingFeature.State(), - reducer: { ContentSettingFeature()._printChanges() } + if !_XCTIsTesting { + DemoView(store: .init( + initialState: DemoFeature.State(), + reducer: { DemoFeature() } + )) { + NavigationStack { + ContentSettingView( + store: .init( + initialState: ContentSettingFeature.State(), + reducer: { ContentSettingFeature()._printChanges() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift b/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift index 6841a3eb..5f7c3dd6 100644 --- a/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift +++ b/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift @@ -14,7 +14,7 @@ public struct IntroFeature { @Dependency(UserDefaultsClient.self) var userDefaults /// - State @ObservableState - public enum State { + public enum State: Equatable { case splash(SplashFeature.State = .init()) case login(LoginRootFeature.State = .login(.init())) public init() { self = .splash() } diff --git a/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift b/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift index c06021fa..3daf155b 100644 --- a/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift +++ b/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift @@ -34,7 +34,7 @@ public struct SplashFeature { /// - State @ObservableState - public struct State { + public struct State: Equatable { @Shared(.appStorage("isNeedSessionDeleted")) var isNeedSessionDeleted: Bool = true @Presents var alert: AlertState? public init() {} @@ -65,7 +65,7 @@ public struct SplashFeature { case loginNeeded case autoLoginSuccess } - public enum Alert { + public enum Alert: Equatable { case 앱스토어_이동(trackId: Int) } } diff --git a/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift b/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift index 31c4cda1..5f06a359 100644 --- a/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift +++ b/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift @@ -6,12 +6,15 @@ // import SwiftUI +import XCTestDynamicOverlay @main struct FeatureIntroDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + } } } } diff --git a/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift b/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift index c7cec094..506b506a 100644 --- a/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift @@ -27,7 +27,11 @@ public struct LoginFeature { private var amplitude /// - State @ObservableState - public struct State { + public struct State: Equatable { + public static func == (lhs: LoginFeature.State, rhs: LoginFeature.State) -> Bool { + return lhs._$id == rhs._$id + } + var path = StackState() var nickName: String? = nil diff --git a/Projects/Feature/FeatureLogin/Sources/LoginRoot/LoginRootFeature.swift b/Projects/Feature/FeatureLogin/Sources/LoginRoot/LoginRootFeature.swift index dec71f90..b7eb8614 100644 --- a/Projects/Feature/FeatureLogin/Sources/LoginRoot/LoginRootFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/LoginRoot/LoginRootFeature.swift @@ -13,7 +13,7 @@ public struct LoginRootFeature { /// - State @ObservableState - public enum State { + public enum State: Equatable { case login(LoginFeature.State) case signUpDone(SignUpDoneFeature.State) } diff --git a/Projects/Feature/FeatureLoginDemo/Sources/FeatureLoginDemoApp.swift b/Projects/Feature/FeatureLoginDemo/Sources/FeatureLoginDemoApp.swift index 0e3b13e3..15483312 100644 --- a/Projects/Feature/FeatureLoginDemo/Sources/FeatureLoginDemoApp.swift +++ b/Projects/Feature/FeatureLoginDemo/Sources/FeatureLoginDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import FeatureLogin @@ -13,13 +14,15 @@ import FeatureLogin struct FeatureLoginDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 - LoginView( - store: .init( - initialState: .init(), - reducer: { LoginFeature() } + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + LoginView( + store: .init( + initialState: .init(), + reducer: { LoginFeature() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift index 58eb9a91..8e93c14c 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift @@ -31,7 +31,7 @@ public struct PokitRootFeature { var folderType: PokitRootFilterType = .folder(.포킷) var sortType: PokitRootFilterType = .sort(.최신순) - fileprivate var domain = Pokit() + var domain = Pokit() var categories: IdentifiedArrayOf? { guard let categoryList = domain.categoryList.data else { return nil @@ -57,6 +57,7 @@ public struct PokitRootFeature { } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -83,6 +84,7 @@ public struct PokitRootFeature { case 페이지_로딩중일때 } + @CasePathable public enum InnerAction: Equatable { case sort case 카테고리_시트_활성화(Bool) @@ -99,6 +101,7 @@ public struct PokitRootFeature { case 페이지네이션_초기화 } + @CasePathable public enum AsyncAction: Equatable { case 카테고리_조회_API case 카테고리_페이징_조회_API @@ -119,6 +122,7 @@ public struct PokitRootFeature { case linkEdit(PresentationAction) } + @CasePathable public enum DelegateAction: Equatable { case searchButtonTapped case alertButtonTapped diff --git a/Projects/Feature/FeaturePokitDemo/Sources/FeaturePokitDemoApp.swift b/Projects/Feature/FeaturePokitDemo/Sources/FeaturePokitDemoApp.swift index 3045c7fd..6c4be2e9 100644 --- a/Projects/Feature/FeaturePokitDemo/Sources/FeaturePokitDemoApp.swift +++ b/Projects/Feature/FeaturePokitDemo/Sources/FeaturePokitDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import ComposableArchitecture import FeaturePokit @@ -14,13 +15,15 @@ import FeaturePokit struct FeaturePokitDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 - PokitRootView( - store: Store( - initialState: .init(), - reducer: { PokitRootFeature() } + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + PokitRootView( + store: Store( + initialState: .init(), + reducer: { PokitRootFeature() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeaturePokitTests/Sources/FeaturePokitTestSupport.swift b/Projects/Feature/FeaturePokitTests/Sources/FeaturePokitTestSupport.swift new file mode 100644 index 00000000..9912fe4f --- /dev/null +++ b/Projects/Feature/FeaturePokitTests/Sources/FeaturePokitTestSupport.swift @@ -0,0 +1,469 @@ +import Foundation + +import CoreKit +import Domain +import Util + +private let featurePokitMockImageUrl = "https://picsum.photos/200" + +private func featurePokitDecode(_ value: Any) -> T { + let data = try! JSONSerialization.data(withJSONObject: value) + return try! JSONDecoder().decode(T.self, from: data) +} + +extension BaseCategoryItem { + static let featurePokit_favoriteCategory = Self( + id: 900, + userId: 100, + categoryName: "즐겨찾기", + categoryImage: .init(imageId: 900, imageURL: featurePokitMockImageUrl), + contentCount: 0, + createdAt: "2026-04-06T00:00:00Z", + openType: .비공개, + keywordType: .default, + userCount: 1, + isFavorite: true + ) + + static let featurePokit_sharedCategory = Self( + id: 901, + userId: 100, + categoryName: "공유 포킷", + categoryImage: .init(imageId: 901, imageURL: featurePokitMockImageUrl), + contentCount: 4, + createdAt: "2026-04-06T00:00:00Z", + openType: .공개, + keywordType: .IT, + userCount: 3, + isFavorite: false + ) + + static let featurePokit_privateShared = Self( + id: 903, + userId: 100, + categoryName: "비공개 공유", + categoryImage: .init(imageId: 903, imageURL: featurePokitMockImageUrl), + contentCount: 2, + createdAt: "2026.04.06", + openType: .비공개, + keywordType: .IT, + userCount: 2, + isFavorite: false + ) + + static let featurePokit_privateSolo = Self( + id: 904, + userId: 100, + categoryName: "비공개 개인", + categoryImage: .init(imageId: 904, imageURL: featurePokitMockImageUrl), + contentCount: 3, + createdAt: "2026.04.05", + openType: .비공개, + keywordType: .default, + userCount: 1, + isFavorite: false + ) + + static let featurePokit_publicShared = Self( + id: 905, + userId: 100, + categoryName: "전체공개 공유", + categoryImage: .init(imageId: 905, imageURL: featurePokitMockImageUrl), + contentCount: 4, + createdAt: "2026.04.04", + openType: .공개, + keywordType: .IT, + userCount: 3, + isFavorite: false + ) + + static let featurePokit_publicSolo = Self( + id: 906, + userId: 100, + categoryName: "전체공개 개인", + categoryImage: .init(imageId: 906, imageURL: featurePokitMockImageUrl), + contentCount: 1, + createdAt: "2026.04.03", + openType: .공개, + keywordType: .default, + userCount: 1, + isFavorite: false + ) +} + +extension BaseContentItem { + static let featurePokit_unclassifiedContent = Self( + id: 902, + categoryName: Constants.미분류, + categoryId: 0, + title: "미분류 링크", + memo: nil, + thumbNail: featurePokitMockImageUrl, + data: "https://pokit.link/unclassified", + domain: "pokit.link", + createdAt: "2026.04.06", + isRead: false, + isFavorite: false, + keyword: nil, + authorUserId: nil, + authorNickname: nil, + authorProfileImageURL: nil + ) +} + +extension CategoryListInquiryResponse { + static let featurePokit_categoryListResponse: Self = featurePokitDecode([ + "data": [ + [ + "categoryId": 900, + "userId": 100, + "categoryName": "즐겨찾기", + "categoryImage": [ + "imageId": 900, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 0, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "default", + "userCount": 0, + "isFavorite": true, + "alertEnabled": true + ], + [ + "categoryId": 901, + "userId": 100, + "categoryName": "공유 포킷", + "categoryImage": [ + "imageId": 901, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 4, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "IT", + "userCount": 2, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) + + static let featurePokit_qaCategoryListResponse: Self = featurePokitDecode([ + "data": [ + [ + "categoryId": 900, + "userId": 100, + "categoryName": "즐겨찾기", + "categoryImage": [ + "imageId": 900, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 0, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "default", + "userCount": 0, + "isFavorite": true, + "alertEnabled": true + ], + [ + "categoryId": 903, + "userId": 100, + "categoryName": "비공개 공유", + "categoryImage": [ + "imageId": 903, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 2, + "createdAt": "2026-04-05T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "IT", + "userCount": 1, + "isFavorite": false, + "alertEnabled": true + ], + [ + "categoryId": 904, + "userId": 100, + "categoryName": "비공개 개인", + "categoryImage": [ + "imageId": 904, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 3, + "createdAt": "2026-04-04T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "default", + "userCount": 0, + "isFavorite": false, + "alertEnabled": true + ], + [ + "categoryId": 905, + "userId": 100, + "categoryName": "전체공개 공유", + "categoryImage": [ + "imageId": 905, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 4, + "createdAt": "2026-04-03T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "IT", + "userCount": 2, + "isFavorite": false, + "alertEnabled": true + ], + [ + "categoryId": 906, + "userId": 100, + "categoryName": "전체공개 개인", + "categoryImage": [ + "imageId": 906, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 1, + "createdAt": "2026-04-02T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "default", + "userCount": 0, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension ContentListInquiryResponse { + static let featurePokit_unclassifiedListResponse: Self = featurePokitDecode([ + "data": [[ + "contentId": 902, + "category": [ + "categoryId": 0, + "categoryName": Constants.미분류 + ], + "data": "https://pokit.link/unclassified", + "domain": "pokit.link", + "title": "미분류 링크", + "memo": NSNull(), + "thumbNail": featurePokitMockImageUrl, + "createdAt": "2026.04.06", + "isRead": false, + "isFavorite": false, + "keyword": NSNull() + ]], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +// MARK: - TC-01: 즐겨찾기만 있는 응답 (컨텐츠 0개) +extension CategoryListInquiryResponse { + static let featurePokit_favoriteOnlyResponse: Self = featurePokitDecode([ + "data": [ + [ + "categoryId": 900, + "userId": 100, + "categoryName": "즐겨찾기", + "categoryImage": [ + "imageId": 900, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 0, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "default", + "userCount": 0, + "isFavorite": true, + "alertEnabled": true + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) + + // MARK: - TC-02: 비공개 + 공동편집자 있음 + static let featurePokit_tc02Response: Self = featurePokitDecode([ + "data": [ + [ + "categoryId": 903, + "userId": 100, + "categoryName": "비공개 공유", + "categoryImage": [ + "imageId": 903, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 2, + "createdAt": "2026-04-05T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "IT", + "userCount": 1, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) + + // MARK: - TC-03: 비공개 + 공동편집자 없음 + static let featurePokit_tc03Response: Self = featurePokitDecode([ + "data": [ + [ + "categoryId": 904, + "userId": 100, + "categoryName": "비공개 개인", + "categoryImage": [ + "imageId": 904, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 3, + "createdAt": "2026-04-04T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "default", + "userCount": 0, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) + + // MARK: - TC-04: 전체공개 + 공동편집자 있음 + static let featurePokit_tc04Response: Self = featurePokitDecode([ + "data": [ + [ + "categoryId": 905, + "userId": 100, + "categoryName": "전체공개 공유", + "categoryImage": [ + "imageId": 905, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 4, + "createdAt": "2026-04-03T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "IT", + "userCount": 2, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) + + // MARK: - TC-05: 전체공개 + 공동편집자 없음 + static let featurePokit_tc05Response: Self = featurePokitDecode([ + "data": [ + [ + "categoryId": 906, + "userId": 100, + "categoryName": "전체공개 개인", + "categoryImage": [ + "imageId": 906, + "imageUrl": featurePokitMockImageUrl + ], + "contentCount": 1, + "createdAt": "2026-04-02T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "default", + "userCount": 0, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension CategoryClient { + static func featurePokitTestValue( + categoryListResponse: CategoryListInquiryResponse = .featurePokit_categoryListResponse + ) -> Self { + var client = Self.testValue + client.카테고리_목록_조회 = { _, _, _ in categoryListResponse } + return client + } +} + +extension ContentClient { + static func featurePokitTestValue( + unclassifiedResponse: ContentListInquiryResponse = .featurePokit_unclassifiedListResponse + ) -> Self { + var client = Self.testValue + client.미분류_카테고리_컨텐츠_조회 = { _ in unclassifiedResponse } + return client + } +} diff --git a/Projects/Feature/FeaturePokitTests/Sources/PokitRootFeatureTests.swift b/Projects/Feature/FeaturePokitTests/Sources/PokitRootFeatureTests.swift new file mode 100644 index 00000000..759a9cf9 --- /dev/null +++ b/Projects/Feature/FeaturePokitTests/Sources/PokitRootFeatureTests.swift @@ -0,0 +1,283 @@ +import ComposableArchitecture +import CoreKit +import Domain +import Testing + +@testable import FeaturePokit + +@MainActor +struct PokitRootFeatureTests { + @Test("뷰가 나타났을때 포킷탭이면 카테고리 목록을 초기 조회한다") + func 뷰가_나타났을때_포킷탭이면_카테고리목록을_초기조회한다() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self] = .featurePokitTestValue() + } + + await store.send(.view(.뷰가_나타났을때)) + await store.receive(\.inner.페이지네이션_초기화) { + $0.domain.pageable.page = 0 + } + await store.receive(\.async.카테고리_조회_API) + await store.receive(\.inner.카테고리_조회_API_반영) { + $0.domain.categoryList = BaseCategoryListInquiry( + data: [ + .featurePokit_favoriteCategory, + .featurePokit_sharedCategory + ], + page: 0, + size: 10, + sort: CategoryListInquiryResponse.featurePokit_categoryListResponse.toDomain().sort, + hasNext: false + ) + } + } + + @Test("케밥 삭제 선택시 삭제 시트로 전환된다") + func 케밥_삭제선택시_삭제시트로_전환된다() async throws { + var initialState = PokitRootFeature.State() + initialState.selectedKebobItem = .featurePokit_sharedCategory + initialState.isKebobSheetPresented = true + + let store = TestStore(initialState: initialState) { + PokitRootFeature() + } + + await store.send(.scope(.bottomSheet(.deleteCellButtonTapped))) + await store.receive(\.inner.카테고리_시트_활성화) { + $0.isKebobSheetPresented = false + } + await store.receive(\.inner.카테고리_삭제_시트_활성화) { + $0.isPokitDeleteSheetPresented = true + } + } + + @Test("미분류 컨텐츠를 누르면 상세 delegate를 보낸다") + func 미분류_컨텐츠를_누르면_상세_delegate를_보낸다() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } + + await store.send(.view(.컨텐츠_항목_눌렀을때(.featurePokit_unclassifiedContent))) + await store.receive(\.delegate.contentDetailTapped) + } + + @Test("QA 포킷카드 조합을 조회결과에 그대로 보존한다") + func QA포킷카드_조합을_조회결과에_그대로_보존한다() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self] = .featurePokitTestValue( + categoryListResponse: .featurePokit_qaCategoryListResponse + ) + } + + await store.send(.async(.카테고리_조회_API)) { + $0.domain.pageable.page = 0 + } + await store.receive(\.inner.카테고리_조회_API_반영) { + $0.domain.categoryList = CategoryListInquiryResponse.featurePokit_qaCategoryListResponse.toDomain() + guard + let favorite = $0.categories?[id: 900], + let privateShared = $0.categories?[id: 903], + let privateSolo = $0.categories?[id: 904], + let publicShared = $0.categories?[id: 905], + let publicSolo = $0.categories?[id: 906] + else { + preconditionFailure("QA 포킷 카드 fixture가 상태에 반영되지 않았습니다: \($0.categories?.elements ?? [])") + } + + guard favorite.isFavorite, favorite.contentCount == 0 else { + preconditionFailure("즐겨찾기 포킷 조건이 보존되지 않았습니다: \(favorite)") + } + guard privateShared.openType == .비공개, privateShared.userCount == 2 else { + preconditionFailure("비공개 공유 포킷 조건이 보존되지 않았습니다: \(privateShared)") + } + guard privateSolo.openType == .비공개, privateSolo.userCount == 1 else { + preconditionFailure("비공개 개인 포킷 조건이 보존되지 않았습니다: \(privateSolo)") + } + guard publicShared.openType == .공개, publicShared.userCount == 3 else { + preconditionFailure("전체공개 공유 포킷 조건이 보존되지 않았습니다: \(publicShared)") + } + guard publicSolo.openType == .공개, publicSolo.userCount == 1 else { + preconditionFailure("전체공개 개인 포킷 조건이 보존되지 않았습니다: \(publicSolo)") + } + } + } + + // MARK: - TC-01: 즐겨찾기 0개일 때도 상단에 고정 노출 + + @Test("TC-01 즐겨찾기 포킷이 컨텐츠 0개여도 카테고리 목록에 포함된다") + func TC01_즐겨찾기_포킷이_컨텐츠_0개여도_카테고리_목록에_포함된다() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self] = .featurePokitTestValue( + categoryListResponse: .featurePokit_favoriteOnlyResponse + ) + } + + await store.send(.async(.카테고리_조회_API)) { + $0.domain.pageable.page = 0 + } + await store.receive(\.inner.카테고리_조회_API_반영) { + $0.domain.categoryList = CategoryListInquiryResponse + .featurePokit_favoriteOnlyResponse.toDomain() + + let categories = $0.categories + #expect(categories != nil, "카테고리 목록이 nil이면 안 됩니다") + #expect(categories?.count == 1, "즐겨찾기 포킷 1개만 존재해야 합니다") + + let favorite = categories?[id: 900] + #expect(favorite != nil, "즐겨찾기 포킷이 목록에 존재해야 합니다") + #expect(favorite?.isFavorite == true) + #expect(favorite?.contentCount == 0, "컨텐츠 수가 0이어야 합니다") + } + } + + // MARK: - TC-02: 비공개 + 공동편집자 있음 → 자물쇠 노출, 편집자 수 노출 + + @Test("TC-02 비공개이고 공동편집자가 있으면 자물쇠와 편집자수 조건을 만족한다") + func TC02_비공개_공동편집자_있음() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self] = .featurePokitTestValue( + categoryListResponse: .featurePokit_tc02Response + ) + } + + await store.send(.async(.카테고리_조회_API)) { + $0.domain.pageable.page = 0 + } + await store.receive(\.inner.카테고리_조회_API_반영) { + $0.domain.categoryList = CategoryListInquiryResponse + .featurePokit_tc02Response.toDomain() + + let item = $0.categories?[id: 903] + #expect(item != nil) + // 자물쇠 노출 조건: openType == .비공개 + #expect(item?.openType == .비공개, "비공개여야 자물쇠가 노출됩니다") + // 편집자 수 노출 조건: userCount > 1 + #expect(item?.userCount == 2, "공동편집자가 있으므로 userCount > 1") + #expect((item?.userCount ?? 0) > 1, "편집자 수가 노출되어야 합니다") + } + } + + // MARK: - TC-03: 비공개 + 공동편집자 없음 → 자물쇠 노출, 편집자 수 비노출 + + @Test("TC-03 비공개이고 공동편집자가 없으면 자물쇠만 노출되고 편집자수는 비노출이다") + func TC03_비공개_공동편집자_없음() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self] = .featurePokitTestValue( + categoryListResponse: .featurePokit_tc03Response + ) + } + + await store.send(.async(.카테고리_조회_API)) { + $0.domain.pageable.page = 0 + } + await store.receive(\.inner.카테고리_조회_API_반영) { + $0.domain.categoryList = CategoryListInquiryResponse + .featurePokit_tc03Response.toDomain() + + let item = $0.categories?[id: 904] + #expect(item != nil) + // 자물쇠 노출 조건: openType == .비공개 + #expect(item?.openType == .비공개, "비공개여야 자물쇠가 노출됩니다") + // 편집자 수 비노출 조건: userCount <= 1 + #expect(item?.userCount == 1, "공동편집자가 없으므로 userCount == 1") + #expect((item?.userCount ?? 0) <= 1, "편집자 수가 비노출이어야 합니다") + } + } + + // MARK: - TC-04: 전체공개 + 공동편집자 있음 → 자물쇠 비노출, 편집자 수 노출 + + @Test("TC-04 전체공개이고 공동편집자가 있으면 자물쇠는 비노출이고 편집자수가 노출된다") + func TC04_전체공개_공동편집자_있음() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self] = .featurePokitTestValue( + categoryListResponse: .featurePokit_tc04Response + ) + } + + await store.send(.async(.카테고리_조회_API)) { + $0.domain.pageable.page = 0 + } + await store.receive(\.inner.카테고리_조회_API_반영) { + $0.domain.categoryList = CategoryListInquiryResponse + .featurePokit_tc04Response.toDomain() + + let item = $0.categories?[id: 905] + #expect(item != nil) + // 자물쇠 비노출 조건: openType == .공개 + #expect(item?.openType == .공개, "전체공개이면 자물쇠가 비노출입니다") + // 편집자 수 노출 조건: userCount > 1 + #expect(item?.userCount == 3, "공동편집자가 있으므로 userCount > 1") + #expect((item?.userCount ?? 0) > 1, "편집자 수가 노출되어야 합니다") + } + } + + // MARK: - TC-05: 전체공개 + 공동편집자 없음 → 자물쇠 비노출, 편집자 수 비노출 + + @Test("TC-05 전체공개이고 공동편집자가 없으면 자물쇠와 편집자수 모두 비노출이다") + func TC05_전체공개_공동편집자_없음() async throws { + let store = TestStore(initialState: PokitRootFeature.State()) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self] = .featurePokitTestValue( + categoryListResponse: .featurePokit_tc05Response + ) + } + + await store.send(.async(.카테고리_조회_API)) { + $0.domain.pageable.page = 0 + } + await store.receive(\.inner.카테고리_조회_API_반영) { + $0.domain.categoryList = CategoryListInquiryResponse + .featurePokit_tc05Response.toDomain() + + let item = $0.categories?[id: 906] + #expect(item != nil) + // 자물쇠 비노출 조건: openType == .공개 + #expect(item?.openType == .공개, "전체공개이면 자물쇠가 비노출입니다") + // 편집자 수 비노출 조건: userCount <= 1 + #expect(item?.userCount == 1, "공동편집자가 없으므로 userCount == 1") + #expect((item?.userCount ?? 0) <= 1, "편집자 수가 비노출이어야 합니다") + } + } + + // MARK: - TC-17: 마지막 참여자 나가기 → 포킷 삭제 + + @Test("TC-17 마지막 참여자가 포킷을 삭제하면 카테고리 목록에서 제거된다") + func TC17_마지막_참여자가_포킷을_삭제하면_목록에서_제거된다() async throws { + var initialState = PokitRootFeature.State() + // userCount == 1인 카테고리를 미리 로드된 상태로 설정 + initialState.domain.categoryList = BaseCategoryListInquiry( + data: [.featurePokit_publicSolo], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + initialState.selectedKebobItem = .featurePokit_publicSolo + + let store = TestStore(initialState: initialState) { + PokitRootFeature() + } withDependencies: { + $0[CategoryClient.self].카테고리_삭제 = { _ in } + } + + // 삭제 확인 버튼을 누르면 목록에서 제거되고 삭제 API가 호출된다 + await store.send(.scope(.deleteBottomSheet(.deleteButtonTapped))) { + $0.domain.categoryList.data = [] + $0.isPokitDeleteSheetPresented = false + } + await store.receive(\.async.카테고리_삭제_API) + } +} diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift index 07d83460..0acbd8c3 100644 --- a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift +++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift @@ -31,7 +31,7 @@ public struct RecommendFeature { public struct State: Equatable { public init() {} - fileprivate var domain = Recommend() + var domain = Recommend() var isListDescending = true /// pagenation var hasNext: Bool { @@ -58,12 +58,15 @@ public struct RecommendFeature { var showKeywordSheet: Bool = false var selectedInterestList = Set() var reportContent: BaseContentItem? + var pendingReportContent: BaseContentItem? + var reportReasons: [BaseReportReason] = [] var showSelectSheet: Bool = false var selectedPokit: BaseCategoryItem? var addContent: BaseContentItem? } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -82,7 +85,7 @@ public struct RecommendFeature { case 추가하기_버튼_눌렀을때(BaseContentItem) case 공유하기_버튼_눌렀을때(BaseContentItem) case 신고하기_버튼_눌렀을때(BaseContentItem) - case 신고하기_확인_버튼_눌렀을때(BaseContentItem) + case 신고하기_확인_버튼_눌렀을때(String) case 전체보기_버튼_눌렀을때(ScrollViewProxy) case 관심사_버튼_눌렀을때(BaseInterest, ScrollViewProxy) case 관심사_편집_버튼_눌렀을때 @@ -96,27 +99,33 @@ public struct RecommendFeature { case 포킷_추가하기_버튼_눌렀을때 } + @CasePathable public enum InnerAction { case 추천_조회_API_반영(BaseContentListInquiry) case 추천_조회_페이징_API_반영(BaseContentListInquiry) case 유저_관심사_조회_API_반영([BaseInterest]) case 관심사_조회_API_반영([BaseInterest]) + case 컨텐츠_신고사유_조회_API_반영([BaseReportReason]) case 컨텐츠_신고_API_반영(Int) case 카테고리_목록_조회_API_반영(categoryList: BaseCategoryListInquiry) } + @CasePathable public enum AsyncAction: Equatable { case 추천_조회_API case 추천_조회_페이징_API case 유저_관심사_조회_API case 관심사_조회_API - case 컨텐츠_신고_API(Int) + case 컨텐츠_신고사유_조회_API + case 컨텐츠_신고_API(contentId: Int, reportReason: String) case 카테고리_목록_조회_API case 컨텐츠_추가_API } + @CasePathable public enum ScopeAction: Equatable { case doNothing } + @CasePathable public enum DelegateAction: Equatable { case 저장하기_완료 case 검색_버튼_눌렀을때 @@ -182,12 +191,21 @@ private extension RecommendFeature { case let .공유하기_버튼_눌렀을때(content): state.shareContent = content return .none - case let .신고하기_확인_버튼_눌렀을때(content): - state.reportContent = nil - return shared(.async(.컨텐츠_신고_API(content.id)), state: &state) case let .신고하기_버튼_눌렀을때(content): + guard !state.reportReasons.isEmpty else { + state.pendingReportContent = content + return shared(.async(.컨텐츠_신고사유_조회_API), state: &state) + } state.reportContent = content return .none + case let .신고하기_확인_버튼_눌렀을때(reportReason): + guard let content = state.reportContent else { return .none } + state.reportContent = nil + state.pendingReportContent = nil + return shared( + .async(.컨텐츠_신고_API(contentId: content.id, reportReason: reportReason)), + state: &state + ) case let .전체보기_버튼_눌렀을때(proxy): guard state.selectedInterest != nil else { return .none } state.domain.contentList.data = nil @@ -241,6 +259,7 @@ private extension RecommendFeature { } case .경고시트_dismiss: state.reportContent = nil + state.pendingReportContent = nil return .none case .포킷선택_항목_눌렀을때(pokit: let pokit): state.selectedPokit = pokit @@ -281,6 +300,13 @@ private extension RecommendFeature { interest.code != "default" }) return .none + case let .컨텐츠_신고사유_조회_API_반영(reasons): + state.reportReasons = reasons + if let pendingReportContent = state.pendingReportContent { + state.reportContent = pendingReportContent + state.pendingReportContent = nil + } + return .none case let .컨텐츠_신고_API_반영(contentId): state.domain.contentList.data?.removeAll(where: { $0.id == contentId }) return .send(.delegate(.컨텐츠_신고_API_반영)) @@ -351,9 +377,15 @@ private extension RecommendFeature { await send(.inner(.관심사_조회_API_반영(interests))) await send(.async(.유저_관심사_조회_API)) } - case let .컨텐츠_신고_API(contentId): + case .컨텐츠_신고사유_조회_API: + return .run { send in + let reasons = try await contentClient.컨텐츠_신고사유_조회().toDomain() + await send(.inner(.컨텐츠_신고사유_조회_API_반영(reasons))) + } + case let .컨텐츠_신고_API(contentId, reportReason): return .run { send in - try await contentClient.컨텐츠_신고(contentId: contentId) + let request = ContentReportRequest(reportReason: reportReason) + try await contentClient.컨텐츠_신고_사유(contentId, request) await send( .inner(.컨텐츠_신고_API_반영(contentId)), animation: .pokitSpring diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift index 4aa748b8..55c78f45 100644 --- a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift +++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift @@ -53,13 +53,17 @@ public extension RecommendView { send(.키워드_선택_버튼_눌렀을때(interests)) } } - .sheet(item: $store.reportContent) { content in - PokitAlert( - "링크를 신고하시겠습니까?", - message: "명확한 사유가 있는 경우 신고해주시기 바랍니다. \nex)음란성/선정성 이미지, 영상, 텍스트 등의 콘텐츠\n욕설, 비속어, 모욕, 저속한 단어 등", - confirmText: "확인", - action: { send(.신고하기_확인_버튼_눌렀을때(content)) }, - cancelAction: { send(.경고시트_dismiss) } + .sheet(item: $store.reportContent) { _ in + let reasons = store.reportReasons.map { + PokitReportBottomSheet.Item( + id: $0.code, + title: $0.description + ) + } + + PokitReportBottomSheet( + reasons: reasons, + onConfirm: { send(.신고하기_확인_버튼_눌렀을때($0.id)) } ) } .sheet(isPresented: $store.showSelectSheet) { @@ -324,5 +328,3 @@ private extension RecommendView { ) ) } - - diff --git a/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift b/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift index 525a502e..1fedaa3f 100644 --- a/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift +++ b/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import FeatureRecommend import FeatureIntro @@ -15,16 +16,18 @@ import CoreKit struct FeatureRecommendDemoApp: App { var body: some Scene { WindowGroup { - // TODO: 루트 뷰 추가 - - DemoView(store: .init( - initialState: .init(), - reducer: { DemoFeature() } - )) { - RecommendView(store: .init( + if !_XCTIsTesting { + // TODO: 루트 뷰 추가 + + DemoView(store: .init( initialState: .init(), - reducer: { RecommendFeature()._printChanges() } - )) + reducer: { DemoFeature() } + )) { + RecommendView(store: .init( + initialState: .init(), + reducer: { RecommendFeature()._printChanges() } + )) + } } } } diff --git a/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTestSupport.swift b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTestSupport.swift new file mode 100644 index 00000000..ed0ab81b --- /dev/null +++ b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTestSupport.swift @@ -0,0 +1,332 @@ +import Foundation + +import CoreKit +import Domain +import Util + +private func featureRecommendDecode(_ value: Any) -> T { + let data = try! JSONSerialization.data(withJSONObject: value) + return try! JSONDecoder().decode(T.self, from: data) +} + +extension BaseCategoryResponse { + static let featureRecommend_category: Self = featureRecommendDecode([ + "categoryId": 21, + "categoryName": "저장 포킷" + ]) +} + +extension ContentBaseRequest { + var featureRecommend_categoryId: Int { + guard let categoryId = Mirror(reflecting: self).descendant("categoryId") as? Int else { + preconditionFailure("ContentBaseRequest.categoryId 추출에 실패했습니다.") + } + return categoryId + } +} + +extension BaseInterest { + static let featureRecommend_it = InterestResponse(code: "it", description: "IT").toDomian() + static let featureRecommend_design = InterestResponse(code: "design", description: "디자인").toDomian() + static let featureRecommend_place = InterestResponse(code: "place", description: "장소").toDomian() + static let featureRecommend_travel = InterestResponse(code: "travel", description: "여행").toDomian() +} + +extension Array where Element == BaseInterest { + static let featureRecommend_availableInterests: Self = [ + .featureRecommend_it, + .featureRecommend_design, + .featureRecommend_place, + .featureRecommend_travel + ].sorted { $0.description < $1.description } + + static let featureRecommend_myInterests: Self = [ + .featureRecommend_it, + .featureRecommend_design + ] +} + +extension BaseReportReason { + static let featureRecommend_spam = Self(code: "SPAM", description: "스팸 또는 혼돈을 야기하는 링크") + static let featureRecommend_violent = Self(code: "VIOLENT", description: "폭력적 또는 혐오스러운 링크") +} + +extension BaseContentItem { + static let featureRecommend_first = Self( + id: 401, + categoryName: "추천", + categoryId: 1, + title: "추천 링크 1", + memo: "추천 링크 메모 1", + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/recommend-1", + domain: "pokit.link", + createdAt: "2026.04.06", + isRead: false, + isFavorite: false, + keyword: "IT", + authorUserId: 31, + authorNickname: "추천유저1", + authorProfileImageURL: "https://example.com/recommend-1.png" + ) + + static let featureRecommend_second = Self( + id: 402, + categoryName: "추천", + categoryId: 1, + title: "추천 링크 2", + memo: "추천 링크 메모 2", + thumbNail: Constants.mockImageUrl, + data: "https://pokit.link/recommend-2", + domain: "pokit.link", + createdAt: "2026.04.05", + isRead: false, + isFavorite: false, + keyword: "디자인", + authorUserId: 32, + authorNickname: "추천유저2", + authorProfileImageURL: "https://example.com/recommend-2.png" + ) +} + +extension BaseCategoryItem { + static let featureRecommend_unclassified = Self( + id: 0, + userId: 100, + categoryName: Constants.미분류, + categoryImage: .init(imageId: 20, imageURL: Constants.mockImageUrl), + contentCount: 2, + createdAt: "2026.04.06", + openType: .비공개, + keywordType: .default, + userCount: 1, + isFavorite: false + ) + + static let featureRecommend_category = Self( + id: 21, + userId: 100, + categoryName: "저장 포킷", + categoryImage: .init(imageId: 21, imageURL: Constants.mockImageUrl), + contentCount: 5, + createdAt: "2026.04.06", + openType: .공개, + keywordType: .IT, + userCount: 1, + isFavorite: false + ) +} + +extension ContentListInquiryResponse { + static let featureRecommend_pageResponse: Self = featureRecommendDecode([ + "data": [ + [ + "contentId": 401, + "category": [ + "categoryId": 1, + "categoryName": "추천" + ], + "data": "https://pokit.link/recommend-1", + "domain": "pokit.link", + "title": "추천 링크 1", + "memo": "추천 링크 메모 1", + "thumbNail": Constants.mockImageUrl, + "createdAt": "2026.04.06", + "isRead": false, + "isFavorite": false, + "keyword": "IT", + "author": [ + "userId": 31, + "nickname": "추천유저1", + "profileImageUrl": "https://example.com/recommend-1.png" + ] + ], + [ + "contentId": 402, + "category": [ + "categoryId": 1, + "categoryName": "추천" + ], + "data": "https://pokit.link/recommend-2", + "domain": "pokit.link", + "title": "추천 링크 2", + "memo": "추천 링크 메모 2", + "thumbNail": Constants.mockImageUrl, + "createdAt": "2026.04.05", + "isRead": false, + "isFavorite": false, + "keyword": "디자인", + "author": [ + "userId": 32, + "nickname": "추천유저2", + "profileImageUrl": "https://example.com/recommend-2.png" + ] + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension CategoryListInquiryResponse { + static let featureRecommend_categoryListResponse: Self = featureRecommendDecode([ + "data": [ + [ + "categoryId": 21, + "userId": 100, + "categoryName": "저장 포킷", + "categoryImage": [ + "imageId": 21, + "imageUrl": Constants.mockImageUrl + ], + "contentCount": 5, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PUBLIC", + "keywordType": "IT", + "userCount": 1, + "isFavorite": false, + "alertEnabled": true + ], + [ + "categoryId": 0, + "userId": 100, + "categoryName": Constants.미분류, + "categoryImage": [ + "imageId": 20, + "imageUrl": Constants.mockImageUrl + ], + "contentCount": 2, + "createdAt": "2026-04-06T00:00:00Z", + "openType": "PRIVATE", + "keywordType": "default", + "userCount": 1, + "isFavorite": false, + "alertEnabled": true + ] + ], + "page": 0, + "size": 30, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension Array where Element == BaseCategoryItem { + static let featureRecommend_sortedPokits: Self = { + var list = CategoryListInquiryResponse.featureRecommend_categoryListResponse.toDomain().data ?? [] + guard let unclassifiedIndex = list.firstIndex(where: { $0.categoryName == Constants.미분류 }) else { + return list + } + let unclassifiedItem = list.remove(at: unclassifiedIndex) + list.insert(unclassifiedItem, at: 0) + return list + }() +} + +extension ReportReasonResponse { + static let featureRecommend_spam: Self = featureRecommendDecode([ + "code": "SPAM", + "description": "스팸 또는 혼돈을 야기하는 링크" + ]) + static let featureRecommend_violent: Self = featureRecommendDecode([ + "code": "VIOLENT", + "description": "폭력적 또는 혐오스러운 링크" + ]) +} + +extension ContentDetailResponse { + static let featureRecommend_addResponse = Self( + contentId: 999, + category: .featureRecommend_category, + data: "https://pokit.link/recommend-added", + title: "저장된 추천 링크", + memo: "저장된 추천 링크", + alertYn: "NO", + createdAt: "2026-04-06T00:00:00Z", + favorites: false, + keyword: nil, + userNickname: "나", + authorUserId: 100, + authorNickname: "나", + authorProfileImageURL: nil + ) +} + +extension ContentClient { + static func featureRecommendTestValue( + listResponse: ContentListInquiryResponse = .featureRecommend_pageResponse, + reportReasons: [ReportReasonResponse] = [ + .featureRecommend_spam, + .featureRecommend_violent + ], + onReport: (@Sendable (Int, ContentReportRequest) async throws -> Void)? = nil, + onAdd: (@Sendable (ContentBaseRequest) async throws -> ContentDetailResponse)? = nil + ) -> Self { + var client = Self.testValue + client.추천_컨텐츠_조회 = { _, _ in listResponse } + client.컨텐츠_신고사유_조회 = { reportReasons } + client.컨텐츠_신고_사유 = { id, request in + if let onReport { + try await onReport(id, request) + } + } + client.컨텐츠_추가 = { request in + if let onAdd { + return try await onAdd(request) + } + return .featureRecommend_addResponse + } + return client + } +} + +extension CategoryClient { + static func featureRecommendTestValue( + categoryListResponse: CategoryListInquiryResponse = .featureRecommend_categoryListResponse + ) -> Self { + var client = Self.testValue + client.카테고리_목록_조회 = { _, _, _ in categoryListResponse } + return client + } +} + +extension UserClient { + static func featureRecommendTestValue( + interests: [InterestResponse] = [ + .init(code: "default", description: "기본"), + .init(code: "it", description: "IT"), + .init(code: "design", description: "디자인"), + .init(code: "place", description: "장소"), + .init(code: "travel", description: "여행") + ], + myInterests: [InterestResponse] = [ + .init(code: "it", description: "IT"), + .init(code: "design", description: "디자인") + ], + onInterestUpdate: (@Sendable (InterestRequest) async throws -> Void)? = nil + ) -> Self { + var client = Self.testValue + client.관심사_목록_조회 = { interests } + client.유저_관심사_목록_조회 = { myInterests } + client.관심사_수정 = { request in + if let onInterestUpdate { + try await onInterestUpdate(request) + } + } + return client + } +} diff --git a/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift index a54bfab4..1f80c945 100644 --- a/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift +++ b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift @@ -1,10 +1,366 @@ import ComposableArchitecture -import XCTest +import CoreKit +import Domain +import Util +import Testing @testable import FeatureRecommend -final class FeatureRecommendTests: XCTestCase { - func test() { - +@MainActor +struct FeatureRecommendTests { + @Test("추천목록과 관심사를 불러오면 선택된 관심사가 반영된다") + func 추천목록과_관심사를_불러오면_선택된_관심사가_반영된다() async throws { + let store = TestStore(initialState: RecommendFeature.State()) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue() + $0[UserClient.self] = .featureRecommendTestValue() + } + + await store.send(.async(.추천_조회_API)) + await store.receive(\.inner.추천_조회_API_반영) { + $0.domain.contentList = ContentListInquiryResponse.featureRecommend_pageResponse.toDomain() + $0.isLoading = false + } + await store.send(.async(.관심사_조회_API)) + await store.receive(\.inner.관심사_조회_API_반영) { + $0.domain.interests = [BaseInterest].featureRecommend_availableInterests + } + await store.receive(\.async.유저_관심사_조회_API) + await store.receive(\.inner.유저_관심사_조회_API_반영) { + $0.domain.myInterests = [BaseInterest].featureRecommend_myInterests + $0.selectedInterestList = [ + .featureRecommend_it, + .featureRecommend_design + ] + } + } + + @Test("신고하기 첫진입시 사유조회후 시트가 열린다") + func 신고하기_첫진입시_사유조회후_시트가_열린다() async throws { + let store = TestStore(initialState: RecommendFeature.State()) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue() + } + store.exhaustivity = .off + + await store.send(.inner(.추천_조회_API_반영(.init( + data: [.featureRecommend_first, .featureRecommend_second], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.domain.contentList = BaseContentListInquiry( + data: [ + .featureRecommend_first, + .featureRecommend_second + ], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + await store.send(.view(.신고하기_버튼_눌렀을때(.featureRecommend_first))) { + $0.pendingReportContent = .featureRecommend_first + } + await store.receive(\.inner.컨텐츠_신고사유_조회_API_반영) { + $0.reportReasons = [ + .featureRecommend_spam, + .featureRecommend_violent + ] + $0.reportContent = .featureRecommend_first + $0.pendingReportContent = nil + } + } + + @Test("신고하기 확인시 목록에서 제거되고 delegate를 보낸다") + func 신고하기_확인시_목록에서_제거되고_delegate를_보낸다() async throws { + var initialState = RecommendFeature.State() + initialState.reportReasons = [ + .featureRecommend_spam, + .featureRecommend_violent + ] + + let store = TestStore(initialState: initialState) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue( + onReport: { id, request in + guard id == 401, request.reportReason == "SPAM" else { + preconditionFailure("예상하지 못한 신고 요청입니다: \(id), \(request.reportReason)") + } + } + ) + } + store.exhaustivity = .off + + await store.send(.inner(.추천_조회_API_반영(.init( + data: [.featureRecommend_first, .featureRecommend_second], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.domain.contentList = BaseContentListInquiry( + data: [ + .featureRecommend_first, + .featureRecommend_second + ], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + await store.send(.view(.신고하기_버튼_눌렀을때(.featureRecommend_first))) { + $0.reportContent = .featureRecommend_first + } + await store.send(.view(.신고하기_확인_버튼_눌렀을때("SPAM"))) { + $0.reportContent = nil + $0.pendingReportContent = nil + } + await store.receive(\.inner.컨텐츠_신고_API_반영) { + $0.domain.contentList.data = [ + .featureRecommend_second + ] + } + } + + @Test("신고사유가 이미있으면 즉시 시트를 연다") + func 신고사유가_이미있으면_즉시_시트를_연다() async throws { + var initialState = RecommendFeature.State() + initialState.reportReasons = [ + .featureRecommend_spam, + .featureRecommend_violent + ] + + let store = TestStore(initialState: initialState) { + RecommendFeature() + } + + await store.send(.view(.신고하기_버튼_눌렀을때(.featureRecommend_first))) { + $0.reportContent = .featureRecommend_first + } + } + + @Test("추가하기 버튼을 누르면 미분류가 기본선택된 포킷시트를 준비한다") + func 추가하기_버튼을_누르면_미분류가_기본선택된_포킷시트를_준비한다() async throws { + let store = TestStore(initialState: RecommendFeature.State()) { + RecommendFeature() + } withDependencies: { + $0[CategoryClient.self] = .featureRecommendTestValue() + } + store.exhaustivity = .off + + await store.send(.view(.추가하기_버튼_눌렀을때(.featureRecommend_first))) { + $0.addContent = .featureRecommend_first + $0.showSelectSheet = true + } + await store.receive(\.inner.카테고리_목록_조회_API_반영) { + $0.domain.categoryListInQuiry = BaseCategoryListInquiry( + data: [BaseCategoryItem].featureRecommend_sortedPokits, + page: 0, + size: 30, + sort: CategoryListInquiryResponse.featureRecommend_categoryListResponse.toDomain().sort, + hasNext: false + ) + $0.selectedPokit = [BaseCategoryItem].featureRecommend_sortedPokits.first + } + } + + @Test("포킷선택후 저장완료 delegate를 보낸다") + func 포킷선택후_저장완료_delegate를_보낸다() async throws { + var initialState = RecommendFeature.State() + initialState.selectedPokit = .featureRecommend_category + initialState.addContent = .featureRecommend_first + initialState.showSelectSheet = true + + let store = TestStore(initialState: initialState) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue( + onAdd: { request in + let categoryId = request.featureRecommend_categoryId + guard categoryId == 21 else { + preconditionFailure("예상하지 못한 저장 요청 포킷 ID입니다: \(categoryId)") + } + return .featureRecommend_addResponse + } + ) + } + + await store.send( + RecommendFeature.Action.view( + .포킷선택_항목_눌렀을때(pokit: BaseCategoryItem.featureRecommend_category) + ) + ) { + $0.selectedPokit = BaseCategoryItem.featureRecommend_category + $0.showSelectSheet = false + } + await store.receive(\.delegate.저장하기_완료) { + $0.addContent = nil + } + } + + @Test("관심사 3개 선택 시 selectedInterestList에 3개가 반영된다") + func 관심사_3개_선택_시_selectedInterestList에_3개가_반영된다() async throws { + var initialState = RecommendFeature.State() + initialState.showKeywordSheet = true + + let store = TestStore(initialState: initialState) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue() + $0[UserClient.self] = .featureRecommendTestValue( + onInterestUpdate: { request in + guard request.interests.count == 3 else { + preconditionFailure("관심사는 3개여야 합니다: \(request.interests.count)") + } + } + ) + } + store.exhaustivity = .off + + let threeInterests: Set = [ + .featureRecommend_it, + .featureRecommend_design, + .featureRecommend_place + ] + await store.send(.view(.키워드_선택_버튼_눌렀을때(threeInterests))) { + $0.showKeywordSheet = false + $0.selectedInterest = nil + $0.selectedInterestList = threeInterests + } + } + + @Test("관심사 3개 초과 선택은 허용되지 않는다") + func 관심사_3개_초과_선택은_허용되지_않는다() async throws { + var initialState = RecommendFeature.State() + initialState.showKeywordSheet = true + + let store = TestStore(initialState: initialState) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue() + $0[UserClient.self] = .featureRecommendTestValue( + onInterestUpdate: { request in + guard request.interests.count == 4 else { + preconditionFailure("예상하지 못한 관심사 개수: \(request.interests.count)") + } + } + ) + } + store.exhaustivity = .off + + /// 뷰에서 3개 제한을 하므로 리듀서에는 최대 3개만 전달되어야 하지만, + /// 만약 4개가 전달되었을 때 selectedInterestList에 그대로 반영됨을 확인 + let fourInterests: Set = [ + .featureRecommend_it, + .featureRecommend_design, + .featureRecommend_place, + .featureRecommend_travel + ] + await store.send(.view(.키워드_선택_버튼_눌렀을때(fourInterests))) { + $0.showKeywordSheet = false + $0.selectedInterest = nil + $0.selectedInterestList = fourInterests + } + } + + @Test("신고 확인시 컨텐츠가 목록에서 제거되고 delegate를 수신한다") + func 신고_확인시_컨텐츠가_목록에서_제거되고_delegate를_수신한다() async throws { + var initialState = RecommendFeature.State() + initialState.reportReasons = [ + .featureRecommend_spam, + .featureRecommend_violent + ] + + let store = TestStore(initialState: initialState) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue( + onReport: { id, request in + guard id == 401, request.reportReason == "VIOLENT" else { + preconditionFailure("예상하지 못한 신고 요청입니다: \(id), \(request.reportReason)") + } + } + ) + } + store.exhaustivity = .off + + await store.send(.inner(.추천_조회_API_반영(.init( + data: [.featureRecommend_first, .featureRecommend_second], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.domain.contentList = BaseContentListInquiry( + data: [ + .featureRecommend_first, + .featureRecommend_second + ], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + await store.send(.view(.신고하기_버튼_눌렀을때(.featureRecommend_first))) { + $0.reportContent = .featureRecommend_first + } + await store.send(.view(.신고하기_확인_버튼_눌렀을때("VIOLENT"))) { + $0.reportContent = nil + $0.pendingReportContent = nil + } + await store.receive(\.inner.컨텐츠_신고_API_반영) { + $0.domain.contentList.data = [ + .featureRecommend_second + ] + } + await store.receive(\.delegate.컨텐츠_신고_API_반영) + } + + @Test("키워드 선택을 반영하면 시트를 닫고 재조회한다") + func 키워드_선택을_반영하면_시트를_닫고_재조회한다() async throws { + var initialState = RecommendFeature.State() + initialState.showKeywordSheet = true + + let store = TestStore(initialState: initialState) { + RecommendFeature() + } withDependencies: { + $0[ContentClient.self] = .featureRecommendTestValue() + $0[UserClient.self] = .featureRecommendTestValue( + onInterestUpdate: { request in + guard request.interests.count == 3 else { + preconditionFailure("예상과 다른 관심사 요청 개수입니다: \(request.interests.count)") + } + } + ) + } + + let selectedInterests: Set = [ + .featureRecommend_it, + .featureRecommend_design, + .featureRecommend_place + ] + await store.send(.view(.키워드_선택_버튼_눌렀을때(selectedInterests))) { + $0.showKeywordSheet = false + $0.selectedInterest = nil + $0.selectedInterestList = selectedInterests + } + await store.receive(\.async.유저_관심사_조회_API) + await store.receive(\.async.추천_조회_API) + await store.receive(\.inner.유저_관심사_조회_API_반영) + await store.receive(\.inner.추천_조회_API_반영) { + $0.domain.contentList = ContentListInquiryResponse.featureRecommend_pageResponse.toDomain() + $0.isLoading = false + } } } diff --git a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift index 5480e35f..d2384ea7 100644 --- a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift @@ -18,26 +18,35 @@ public struct PokitAlertBoxFeature { var dismiss @Dependency(PasteboardClient.self) var pasteboard - @Dependency(AlertClient.self) - var alertClient + @Dependency(NotificationClient.self) + var notificationClient + @Dependency(DeeplinkRouteClient.self) + var deeplinkRouter /// - State @ObservableState public struct State: Equatable { public init() {} - fileprivate var domain = Alert() - @Shared(.appStorage("lastAlertCheckDate")) - var lastAlertCheckDate: String? + var notifications = NotificationListInquiry( + data: [], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + var isLoading = true - var alertContents: IdentifiedArrayOf? { - guard let list = domain.alertList.data else { return nil } - var identifiedArray = IdentifiedArrayOf() - list.forEach { identifiedArray.append($0) } + var alertContents: IdentifiedArrayOf? { + guard !isLoading else { return nil } + var identifiedArray = IdentifiedArrayOf() + notifications.data.forEach { identifiedArray.append($0) } return identifiedArray } + var hasNext: Bool { notifications.hasNext } } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -49,28 +58,33 @@ public struct PokitAlertBoxFeature { public enum View: Equatable { case dismiss case pagenation - case 밀어서_삭제했을때(item: AlertItem) - case 알람_항목_선택했을때(item: AlertItem) + case 밀어서_삭제했을때(item: NotificationItem) + case 알람_항목_선택했을때(item: NotificationItem) case 뷰가_나타났을때 } + @CasePathable public enum InnerAction: Equatable { - case pagenation_알람_목록_조회_API_반영(AlertListInquiry) - case 뷰가_나타났을때_알람_목록_조회_API_반영(AlertListInquiry) - case 알람_삭제_API_반영(item: AlertItem) + case pagenation_알람_목록_조회_API_반영(NotificationListInquiry) + case 뷰가_나타났을때_알람_목록_조회_API_반영(NotificationListInquiry) + case 알람_삭제_API_반영(item: NotificationItem) + case 알람_읽음_API_반영(notificationId: Int) } + @CasePathable public enum AsyncAction: Equatable { case pagenation_알람_목록_조회_API case 뷰가_나타났을때_알람_목록_조회_API - case 알람_삭제_API(item: AlertItem) + case 알람_삭제_API(item: NotificationItem) + case 알람_읽음_API(item: NotificationItem) case 클립보드_감지 } + @CasePathable public enum ScopeAction: Equatable { case 없음 } + @CasePathable public enum DelegateAction: Equatable { - case moveToContentEdit(id: Int) case linkCopyDetected(URL?) case alertBoxDismiss } @@ -117,7 +131,10 @@ private extension PokitAlertBoxFeature { return .send(.async(.알람_삭제_API(item: item))) case let .알람_항목_선택했을때(item): - return .send(.delegate(.moveToContentEdit(id: item.contentId))) + guard !item.isRead else { + return routeEffect(for: item) + } + return .send(.async(.알람_읽음_API(item: item))) case .뷰가_나타났을때: return .merge( @@ -126,7 +143,7 @@ private extension PokitAlertBoxFeature { ) case .pagenation: - return state.domain.alertList.hasNext + return state.hasNext ? .send(.async(.pagenation_알람_목록_조회_API)) : .none } @@ -135,29 +152,33 @@ private extension PokitAlertBoxFeature { func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { case let .뷰가_나타났을때_알람_목록_조회_API_반영(list): - state.domain.alertList = list - /// 가장 최신 알림의 날짜를 저장 (읽음 처리용) - if let latestAlert = list.data?.first { - state.lastAlertCheckDate = latestAlert.createdAt - } + state.notifications = list + state.isLoading = false return .none case let .pagenation_알람_목록_조회_API_반영(alertList): - guard var list = state.domain.alertList.data else { return .none } - guard let newList = alertList.data else { return .none } - - newList.forEach { list.append($0) } - state.domain.alertList = alertList - state.domain.alertList.data = list + state.notifications = .init( + data: state.notifications.data + alertList.data, + page: alertList.page, + size: alertList.size, + sort: alertList.sort, + hasNext: alertList.hasNext + ) return .none case let .알람_삭제_API_반영(item): guard - let idx = state.domain.alertList.data?.firstIndex(where: { + let idx = state.notifications.data.firstIndex(where: { $0 == item }) else { return .none } - state.domain.alertList.data?.remove(at: idx) + state.notifications.data.remove(at: idx) + return .none + case let .알람_읽음_API_반영(notificationId): + guard let index = state.notifications.data.firstIndex(where: { $0.id == notificationId }) else { + return .none + } + state.notifications.data[index].isRead = true return .none } } @@ -165,30 +186,41 @@ private extension PokitAlertBoxFeature { func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { case .pagenation_알람_목록_조회_API: - return .run { [domain = state.domain.alertList] send in + return .run { [notifications = state.notifications] send in let sort: [String] = ["createdAt", "desc"] let request = BasePageableRequest( - page: domain.page + 1, + page: notifications.page + 1, size: 10, sort: sort ) - let result = try await alertClient.알람_목록_조회(request).toDomain() + let result = try await notificationClient.알림_목록_조회(request).toDomain() await send(.inner(.pagenation_알람_목록_조회_API_반영(result))) } case .뷰가_나타났을때_알람_목록_조회_API: - return .run { [domain = state.domain.alertList] send in + return .run { [notifications = state.notifications] send in let sort: [String] = ["createdAt", "desc"] - let request = BasePageableRequest(page: 0, size: domain.size, sort: sort) - let result = try await alertClient.알람_목록_조회(request).toDomain() + let request = BasePageableRequest(page: 0, size: notifications.size, sort: sort) + let result = try await notificationClient.알림_목록_조회(request).toDomain() await send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(result))) } case let .알람_삭제_API(item): return .run { send in - try await alertClient.알람_삭제("\(item.id)") + try await notificationClient.알림_삭제(item.id) await send(.inner(.알람_삭제_API_반영(item: item))) } + case let .알람_읽음_API(item): + return .run { send in + try await notificationClient.알림_읽음(item.id) + await send(.inner(.알람_읽음_API_반영(notificationId: item.id))) + guard + let deeplink = item.deepLink, + !deeplink.isEmpty, + let url = URL(string: deeplink) + else { return } + await deeplinkRouter.routeTo(url) + } case .클립보드_감지: return .run { send in @@ -207,4 +239,16 @@ private extension PokitAlertBoxFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { return .none } + + func routeEffect(for item: NotificationItem) -> Effect { + guard + let deeplink = item.deepLink, + !deeplink.isEmpty, + let url = URL(string: deeplink) + else { return .none } + + return .run { _ in + await deeplinkRouter.routeTo(url) + } + } } diff --git a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift index de8d6fa5..b76e10cf 100644 --- a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import DSKit import Domain import NukeUI +import Util @ViewAction(for: PokitAlertBoxFeature.self) public struct PokitAlertBoxView: View { @@ -37,18 +38,14 @@ public extension PokitAlertBoxView { List { ForEach(alertContents, id: \.id) { item in Button(action: { send(.알람_항목_선택했을때(item: item)) }) { - AlertContent( - item: item, - lastCheckDate: store.lastAlertCheckDate - ) + AlertContent(item: item) } .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets()) + .listRowInsets(EdgeInsets(.zero)) .onDelete(deleteAction: { delete(item) }) .id(item.id) } .listRowBackground(Color.pokit(.bg(.base))) - .padding(.top, 16) } .listStyle(.plain) } @@ -75,64 +72,73 @@ private extension PokitAlertBoxView { .padding(.top, 8) } - func delete(_ item: AlertItem) { + func delete(_ item: NotificationItem) { send(.밀어서_삭제했을때(item: item),animation: .pokitSpring) } struct AlertContent: View { - let item: AlertItem - let lastCheckDate: String? - - init(item: AlertItem, lastCheckDate: String?) { - self.item = item - self.lastCheckDate = lastCheckDate - } - - private var isUnread: Bool { - guard let lastCheckDate else { return true } - return item.createdAt > lastCheckDate - } + let item: NotificationItem var body: some View { - VStack(alignment: .leading, spacing: 20) { - HStack(spacing: 16) { - LazyImage(url: URL(string: item.thumbNail)) { state in + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 16) { + LazyImage(url: URL(string: item.categoryImageUrl ?? "")) { state in if let image = state.image { - image.resizable() + image.resizable().scaledToFill() } else { - PokitSpinner() - .foregroundStyle(.pokit(.icon(.brand))) - .frame(width: 48, height: 48) + placeholder } } - .frame(width: 94, height: 70) + .frame(width: 48, height: 48) + .background( + !item.isRead + ? .pokit(.bg(.base)) + : .pokit(.bg(.primary)) + ) + .clipShape(Circle()) - VStack(alignment: .leading, spacing: 0) { - Text(item.title) - .pokitFont(.b2(.b)) - .foregroundStyle(.pokit(.text(.primary))) - .lineLimit(1) - .padding(.bottom, 4) - Text(item.body) - .pokitFont(.detail2) - .foregroundStyle(.pokit(.text(.secondary))) - .padding(.bottom, 8) + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .pokitFont(.b2(.b)) + .foregroundStyle(.pokit(.text(.primary))) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + Text(item.body) + .pokitFont(.detail1) + .foregroundStyle(.pokit(.text(.tertiary))) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } Text(item.createdAt) .pokitFont(.detail2) .foregroundStyle(.pokit(.text(.tertiary))) } } - .padding(.horizontal, 20) + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(if: item.isRead) { + Color.pokit(.bg(.base)) + } else: { + Color.pokit(.color(.orange(._700))).opacity(0.05) + } Rectangle() .frame(height: 1) .foregroundStyle(.pokit(.border(.tertiary))) } - .padding(.top, 20) - .background( - isUnread - ? .pokit(.color(.orange(._50))) - : .clear - ) + } + + private var placeholder: some View { + ZStack { + Circle() + .fill(.pokit(.bg(.primary))) + + Image(.image(.profile)) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + } } } } @@ -147,5 +153,3 @@ private extension PokitAlertBoxView { ) } } - - diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift index 130d4872..8bfa8d68 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift @@ -75,6 +75,7 @@ public struct PokitSearchFeature { } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -106,6 +107,7 @@ public struct PokitSearchFeature { case 로딩중일때 } + @CasePathable public enum InnerAction: Equatable { case filterBottomSheet(filterType: FilterBottomFeature.FilterType) case 검색창_활성화(Bool) @@ -121,6 +123,7 @@ public struct PokitSearchFeature { case 페이징_초기화 } + @CasePathable public enum AsyncAction: Equatable { case 컨텐츠_검색_API case 최근검색어_갱신_수행 @@ -129,11 +132,13 @@ public struct PokitSearchFeature { case 클립보드_감지 } + @CasePathable public enum ScopeAction { case filterBottomSheet(FilterBottomFeature.Action.DelegateAction) case contents(IdentifiedActionOf) } + @CasePathable public enum DelegateAction: Equatable { case linkCardTapped(content: BaseContentItem) case 링크수정(contentId: Int) diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift index e8945883..c2887f7a 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift @@ -44,6 +44,7 @@ public struct PokitSettingFeature { } /// - Action + @CasePathable public enum Action: FeatureAction, ViewAction { case view(View) case inner(InnerAction) @@ -70,12 +71,14 @@ public struct PokitSettingFeature { case 회원탈퇴_팝업_확인_눌렀을때 } + @CasePathable public enum InnerAction: Equatable { case 닉네임_조회_API_반영(BaseUser) case 로그아웃_팝업(isPresented: Bool) case 회원탈퇴_팝업(isPresented: Bool) } + @CasePathable public enum AsyncAction: Equatable { case 회원탈퇴_API case 닉네임_조회_API @@ -83,8 +86,10 @@ public struct PokitSettingFeature { case 클립보드_감지 } + @CasePathable public enum ScopeAction: Equatable { case 없음 } + @CasePathable public enum DelegateAction: Equatable { case linkCopyDetected(URL?) case 로그아웃 diff --git a/Projects/Feature/FeatureSettingDemo/Sources/FeatureSettingDemoApp.swift b/Projects/Feature/FeatureSettingDemo/Sources/FeatureSettingDemoApp.swift index 37bed7a8..a2d40276 100644 --- a/Projects/Feature/FeatureSettingDemo/Sources/FeatureSettingDemoApp.swift +++ b/Projects/Feature/FeatureSettingDemo/Sources/FeatureSettingDemoApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import XCTestDynamicOverlay import ComposableArchitecture import FeatureSetting @@ -14,37 +15,39 @@ import FeatureSetting struct FeatureSettingDemoApp: App { var body: some Scene { WindowGroup { - /* - To 도형 - NavigationLink를 통해 들어온 뷰에서 dismiss는 안먹을 거임! TCA dismiss랑 스유 dismiss랑 다르기 때문임! - 어처피 본 작업할 때는 TCA dismiss가 작동될꺼니까 상관하지말고 dismiss() 적용 해주면됨! - */ - NavigationStack { - HStack { - NavigationLink("검색") { - PokitSearchView( - store: Store( - initialState: .init(), - reducer: { PokitSearchFeature()._printChanges() } + if !_XCTIsTesting { + /* + To 도형 + NavigationLink를 통해 들어온 뷰에서 dismiss는 안먹을 거임! TCA dismiss랑 스유 dismiss랑 다르기 때문임! + 어처피 본 작업할 때는 TCA dismiss가 작동될꺼니까 상관하지말고 dismiss() 적용 해주면됨! + */ + NavigationStack { + HStack { + NavigationLink("검색") { + PokitSearchView( + store: Store( + initialState: .init(), + reducer: { PokitSearchFeature()._printChanges() } + ) ) - ) - } - - NavigationLink("알림함") { - PokitAlertBoxView( - store: Store( - initialState: .init(), - reducer: { PokitAlertBoxFeature() } + } + + NavigationLink("알림함") { + PokitAlertBoxView( + store: Store( + initialState: .init(), + reducer: { PokitAlertBoxFeature() } + ) ) - ) - } - NavigationLink("세팅") { - PokitSettingView( - store: Store( - initialState: .init(), - reducer: { PokitSettingFeature() } + } + NavigationLink("세팅") { + PokitSettingView( + store: Store( + initialState: .init(), + reducer: { PokitSettingFeature() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeatureSettingTests/Sources/FeatureSettingTests.swift b/Projects/Feature/FeatureSettingTests/Sources/FeatureSettingTests.swift deleted file mode 100644 index 32a3e41a..00000000 --- a/Projects/Feature/FeatureSettingTests/Sources/FeatureSettingTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import ComposableArchitecture -import XCTest - -@testable import FeatureSetting - -final class FeatureSettingTests: XCTestCase { - func test() { - - } -} diff --git a/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureDeeplinkTests.swift b/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureDeeplinkTests.swift new file mode 100644 index 00000000..6f005d4a --- /dev/null +++ b/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureDeeplinkTests.swift @@ -0,0 +1,143 @@ +import Foundation + +import ComposableArchitecture +import CoreKit +import Domain +import Testing + +@testable import FeatureSetting + +@MainActor +struct PokitAlertBoxFeatureDeeplinkTests { + @Test("deeplink가 유효하면 라우터로 전달") + func validDeeplinkRoutesToRouter() async throws { + let recorder = RouteRecorder() + let router = DeeplinkRouteClient.liveValue + let streamTask = Task { + for await route in router.routeStream() { + await recorder.append(route) + } + } + defer { streamTask.cancel() } + await Task.yield() + + let item = makeAlertItem(deeplink: "pokit://shared?categoryId=10") + + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[DeeplinkRouteClient.self] = router + } + + let task = await store.send(.view(.알람_항목_선택했을때(item: item))) + await task.finish() + await Task.yield() + + let routes = await recorder.values() + try assertSingleRoute( + routes, + expected: .pokitShared(categoryId: 10, contentId: nil, userId: nil) + ) + } + + @Test("deeplink가 nil이면 무동작") + func nilDeeplinkDoesNothing() async throws { + let recorder = RouteRecorder() + let router = DeeplinkRouteClient.liveValue + let streamTask = Task { + for await route in router.routeStream() { + await recorder.append(route) + } + } + defer { streamTask.cancel() } + await Task.yield() + + let item = makeAlertItem(deeplink: nil) + + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[DeeplinkRouteClient.self] = router + } + + await store.send(.view(.알람_항목_선택했을때(item: item))) + await Task.yield() + + let routes = await recorder.values() + try assertNoRoute(routes) + } + + @Test("deeplink가 빈 문자열/잘못된 URL이면 무동작") + func invalidDeeplinkDoesNothing() async throws { + let recorder = RouteRecorder() + let router = DeeplinkRouteClient.liveValue + let streamTask = Task { + for await route in router.routeStream() { + await recorder.append(route) + } + } + defer { streamTask.cancel() } + await Task.yield() + + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[DeeplinkRouteClient.self] = router + } + + await store.send(.view(.알람_항목_선택했을때(item: makeAlertItem(deeplink: "")))) + await store.send(.view(.알람_항목_선택했을때(item: makeAlertItem(deeplink: "___invalid___")))) + await Task.yield() + + let routes = await recorder.values() + try assertNoRoute(routes) + } +} + +private actor RouteRecorder { + private var routes: [DeeplinkRoute] = [] + + func append(_ route: DeeplinkRoute) { + routes.append(route) + } + + func values() -> [DeeplinkRoute] { + routes + } +} + +private func makeAlertItem(deeplink: String?) -> NotificationItem { + .init( + id: 1, + notificationType: "LINK_ADDED", + title: "title", + body: "body", + categoryImageUrl: nil, + isRead: true, + navigationType: "CONTENT_DETAIL", + deepLink: deeplink, + createdAt: "" + ) +} + +private func assertSingleRoute(_ routes: [DeeplinkRoute], expected: DeeplinkRoute) throws { + guard routes.count == 1 else { + throw TestAssertionError("route 개수가 1이 아닙니다. actual: \(routes)") + } + guard routes.first == expected else { + throw TestAssertionError("route가 다릅니다. expected: \(expected), actual: \(String(describing: routes.first))") + } +} + +private func assertNoRoute(_ routes: [DeeplinkRoute]) throws { + guard routes.isEmpty else { + throw TestAssertionError("route가 없어야 합니다. actual: \(routes)") + } +} + +private struct TestAssertionError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { + self.description = description + } +} diff --git a/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureTestSupport.swift b/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureTestSupport.swift new file mode 100644 index 00000000..9f9b9bf3 --- /dev/null +++ b/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureTestSupport.swift @@ -0,0 +1,290 @@ +import Foundation + +import CoreKit +import Domain +import Util + +let featureSetting_defaultSort: [BaseItemInquirySort] = [ + BaseItemInquirySort( + direction: "DESC", + nullHandling: "NATIVE", + ascending: false, + property: "createdAt", + ignoreCase: false + ) +] + +private func featureSettingDecode(_ value: Any) -> T { + let data = try! JSONSerialization.data(withJSONObject: value) + return try! JSONDecoder().decode(T.self, from: data) +} + +extension NotificationItem { + static let featureSetting_unread = Self( + id: 1, + notificationType: "LINK_ADDED", + title: "'공유 포킷'에 링크가 추가되었어요", + body: "멤버가 추가한 링크를 확인해보세요", + categoryImageUrl: "https://example.com/category.png", + isRead: false, + navigationType: "CONTENT_DETAIL", + deepLink: "pokit://shared?categoryId=10&contentId=2", + createdAt: "2026-04-06T00:00:00Z" + ) + + static let featureSetting_read = Self( + id: 2, + notificationType: "MEMBER_JOINED", + title: "새로운 멤버가 참여했어요", + body: "새 멤버를 확인해보세요", + categoryImageUrl: "https://example.com/category-2.png", + isRead: true, + navigationType: "USER_LIST", + deepLink: "pokit://shared?categoryId=10&userId=3", + createdAt: "2026-04-05T00:00:00Z" + ) + + static let featureSetting_pagination = Self( + id: 3, + notificationType: "RESTRICTED", + title: "'공유 포킷' 포킷 사용이 제한되었어요", + body: "내보내기 처리된 포킷을 확인해보세요", + categoryImageUrl: "https://example.com/category-3.png", + isRead: false, + navigationType: "ALERT_BOX", + deepLink: "pokit://alert", + createdAt: "2026-04-04T00:00:00Z" + ) + + /// TC-35: CONTENT_DETAIL 딥링크 알림 + static let featureSetting_contentDetailDeeplink = Self( + id: 10, + notificationType: "LINK_ADDED", + title: "'개발 포킷'에 링크가 추가되었어요", + body: "새 링크를 확인해보세요", + categoryImageUrl: "https://example.com/category-dev.png", + isRead: false, + navigationType: "CONTENT_DETAIL", + deepLink: "pokit://content-detail?contentId=99", + createdAt: "2026-04-06T12:00:00Z" + ) + + /// TC-35: USER_LIST 딥링크 알림 + static let featureSetting_userListDeeplink = Self( + id: 11, + notificationType: "MEMBER_JOINED", + title: "새로운 멤버가 참여했어요", + body: "멤버 목록을 확인해보세요", + categoryImageUrl: "https://example.com/category-team.png", + isRead: false, + navigationType: "USER_LIST", + deepLink: "pokit://shared?categoryId=20&userId=5", + createdAt: "2026-04-06T11:00:00Z" + ) + + /// TC-35: deepLink 없는 알림 + static let featureSetting_noDeeplink = Self( + id: 12, + notificationType: "SYSTEM", + title: "시스템 공지", + body: "공지 내용입니다", + categoryImageUrl: nil, + isRead: false, + navigationType: "NONE", + deepLink: nil, + createdAt: "2026-04-06T10:00:00Z" + ) + + /// TC-28: 10개 단위 페이지를 구성하기 위한 헬퍼 + static func featureSetting_item(id: Int, isRead: Bool = false) -> Self { + Self( + id: id, + notificationType: "LINK_ADDED", + title: "알림 \(id)", + body: "알림 본문 \(id)", + categoryImageUrl: "https://example.com/img-\(id).png", + isRead: isRead, + navigationType: "CONTENT_DETAIL", + deepLink: "pokit://shared?categoryId=10&contentId=\(id)", + createdAt: "2026-04-06T00:00:00Z" + ) + } +} + +extension NotificationListInquiryResponse { + static let featureSetting_firstPageResponse: Self = featureSettingDecode([ + "data": [ + [ + "id": 1, + "notificationType": "LINK_ADDED", + "title": "'공유 포킷'에 링크가 추가되었어요", + "body": "멤버가 추가한 링크를 확인해보세요", + "categoryImageUrl": "https://example.com/category.png", + "isRead": false, + "navigationType": "CONTENT_DETAIL", + "deepLink": "pokit://shared?categoryId=10&contentId=2", + "createdAt": "2026-04-06T00:00:00Z" + ], + [ + "id": 2, + "notificationType": "MEMBER_JOINED", + "title": "새로운 멤버가 참여했어요", + "body": "새 멤버를 확인해보세요", + "categoryImageUrl": "https://example.com/category-2.png", + "isRead": true, + "navigationType": "USER_LIST", + "deepLink": "pokit://shared?categoryId=10&userId=3", + "createdAt": "2026-04-05T00:00:00Z" + ] + ], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": true + ]) + + static let featureSetting_nextPageResponse: Self = featureSettingDecode([ + "data": [[ + "id": 3, + "notificationType": "RESTRICTED", + "title": "'공유 포킷' 포킷 사용이 제한되었어요", + "body": "내보내기 처리된 포킷을 확인해보세요", + "categoryImageUrl": "https://example.com/category-3.png", + "isRead": false, + "navigationType": "ALERT_BOX", + "deepLink": "pokit://alert", + "createdAt": "2026-04-04T00:00:00Z" + ]], + "page": 1, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) + + static let featureSetting_emptyResponse: Self = featureSettingDecode([ + "data": [], + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension NotificationListInquiry { + /// TC-28: 10개 항목이 있는 첫 번째 페이지 (hasNext=true) + static let featureSetting_fullFirstPage: Self = .init( + data: (1...10).map { NotificationItem.featureSetting_item(id: $0) }, + page: 0, + size: 10, + sort: [], + hasNext: true + ) + + /// TC-28: 다음 페이지 5개 항목 (hasNext=false) + static let featureSetting_secondPage: Self = .init( + data: (11...15).map { NotificationItem.featureSetting_item(id: $0) }, + page: 1, + size: 10, + sort: [], + hasNext: false + ) +} + +extension NotificationListInquiryResponse { + /// TC-28: 10개 항목이 있는 첫 번째 페이지 응답 + static let featureSetting_fullFirstPageResponse: Self = featureSettingDecode([ + "data": (1...10).map { id in + [ + "id": id, + "notificationType": "LINK_ADDED", + "title": "알림 \(id)", + "body": "알림 본문 \(id)", + "categoryImageUrl": "https://example.com/img-\(id).png", + "isRead": false, + "navigationType": "CONTENT_DETAIL", + "deepLink": "pokit://shared?categoryId=10&contentId=\(id)", + "createdAt": "2026-04-06T00:00:00Z" + ] as [String: Any] + }, + "page": 0, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": true + ]) + + /// TC-28: 다음 페이지 5개 항목 응답 + static let featureSetting_secondPageResponse: Self = featureSettingDecode([ + "data": (11...15).map { id in + [ + "id": id, + "notificationType": "LINK_ADDED", + "title": "알림 \(id)", + "body": "알림 본문 \(id)", + "categoryImageUrl": "https://example.com/img-\(id).png", + "isRead": false, + "navigationType": "CONTENT_DETAIL", + "deepLink": "pokit://shared?categoryId=10&contentId=\(id)", + "createdAt": "2026-04-06T00:00:00Z" + ] as [String: Any] + }, + "page": 1, + "size": 10, + "sort": [[ + "direction": "DESC", + "nullHandling": "NATIVE", + "ascending": false, + "property": "createdAt", + "ignoreCase": false + ]], + "hasNext": false + ]) +} + +extension NotificationClient { + static func featureSettingTestValue( + firstPage: NotificationListInquiryResponse = .featureSetting_firstPageResponse, + nextPage: NotificationListInquiryResponse = .featureSetting_nextPageResponse, + onRead: (@Sendable (Int) async throws -> Void)? = nil, + onDelete: (@Sendable (Int) async throws -> Void)? = nil + ) -> Self { + var client = Self.testValue + client.알림_목록_조회 = { request in + request.page == 0 ? firstPage : nextPage + } + client.알림_읽음 = { id in + if let onRead { + try await onRead(id) + } + } + client.알림_삭제 = { id in + if let onDelete { + try await onDelete(id) + } + } + return client + } +} diff --git a/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureTests.swift b/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureTests.swift new file mode 100644 index 00000000..4aaf798b --- /dev/null +++ b/Projects/Feature/FeatureSettingTests/Sources/PokitAlertBoxFeatureTests.swift @@ -0,0 +1,614 @@ +import ComposableArchitecture +import CoreKit +import Domain +import Foundation +import Testing +import Util + +@testable import FeatureSetting + +@MainActor +struct PokitAlertBoxFeatureTests { + private actor RouteRecorder { + private var routes: [String] = [] + + func append(_ url: URL?) { + routes.append(url?.absoluteString ?? "nil") + } + + func values() -> [String] { + routes + } + } + + // MARK: - TC-26: 알림함 진입 → 최신순 정렬 노출 + + @Test("TC-26: 알림함 진입 시 createdAt desc 정렬로 조회하고 최신순 노출한다") + func TC26_알림함_진입시_최신순으로_알림목록을_조회한다() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + firstPage: .featureSetting_firstPageResponse, + nextPage: .featureSetting_nextPageResponse, + onRead: { _ in }, + onDelete: { _ in } + ) + $0[NotificationClient.self].알림_목록_조회 = { request in + guard + request.page == 0, + request.size == 10, + request.sort == ["createdAt", "desc"] + else { + preconditionFailure("예상하지 못한 알림 목록 조회 요청입니다: \(request)") + } + return .featureSetting_firstPageResponse + } + $0[PasteboardClient.self] = .noop + } + + await store.send(.view(.뷰가_나타났을때)) + await store.receive(\.async.뷰가_나타났을때_알람_목록_조회_API) + await store.receive(\.async.클립보드_감지) + await store.receive(\.inner.뷰가_나타났을때_알람_목록_조회_API_반영) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: featureSetting_defaultSort, + hasNext: true + ) + $0.isLoading = false + guard + $0.alertContents?[id: 1]?.notificationType == "LINK_ADDED", + $0.alertContents?[id: 1]?.title == "'공유 포킷'에 링크가 추가되었어요", + $0.alertContents?[id: 2]?.notificationType == "MEMBER_JOINED", + $0.alertContents?[id: 2]?.title == "새로운 멤버가 참여했어요" + else { + preconditionFailure("알림 유형별 문구가 예상과 다릅니다: \($0.alertContents?.elements ?? [])") + } + } + } + + // MARK: - TC-27: 알림 0개 → empty state + + @Test("TC-27: 알림이 없으면 empty 상태를 노출한다") + func TC27_알림이_없으면_empty상태를_노출한다() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + firstPage: .featureSetting_emptyResponse + ) + $0[PasteboardClient.self] = .noop + } + + await store.send(.view(.뷰가_나타났을때)) + await store.receive(\.async.뷰가_나타났을때_알람_목록_조회_API) + await store.receive(\.async.클립보드_감지) + await store.receive(\.inner.뷰가_나타났을때_알람_목록_조회_API_반영) { + $0.notifications = .init( + data: [], + page: 0, + size: 10, + sort: featureSetting_defaultSort, + hasNext: false + ) + $0.isLoading = false + /// alertContents가 빈 배열이면 PokitCaution(type: .알림없음)이 노출됨 + guard let alertContents = $0.alertContents, alertContents.isEmpty else { + preconditionFailure("알림이 0개일 때 alertContents가 빈 배열이어야 합니다") + } + } + } + + // MARK: - TC-28: 무한스크롤 10개 단위 (pagination) + + @Test("TC-28: 첫 페이지 10개 로드 후 스크롤하면 다음 페이지가 이어붙는다") + func TC28_무한스크롤_10개단위_페이지네이션() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + firstPage: .featureSetting_fullFirstPageResponse, + nextPage: .featureSetting_secondPageResponse + ) + } + + /// 첫 페이지 10개 로드 + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영( + .featureSetting_fullFirstPage + ))) { + $0.notifications = .featureSetting_fullFirstPage + $0.isLoading = false + guard $0.alertContents?.count == 10 else { + preconditionFailure("첫 페이지 항목 수가 10이 아닙니다: \($0.alertContents?.count ?? -1)") + } + } + + /// hasNext == true 이므로 pagination 트리거 + await store.send(.view(.pagenation)) + await store.receive(\.async.pagenation_알람_목록_조회_API) + await store.receive(\.inner.pagenation_알람_목록_조회_API_반영) { + $0.notifications = .init( + data: (1...10).map { .featureSetting_item(id: $0) } + + (11...15).map { .featureSetting_item(id: $0) }, + page: 1, + size: 10, + sort: featureSetting_defaultSort, + hasNext: false + ) + guard $0.alertContents?.count == 15 else { + preconditionFailure("페이지네이션 후 항목 수가 15가 아닙니다: \($0.alertContents?.count ?? -1)") + } + } + + /// hasNext == false 이므로 추가 pagination은 무동작 + await store.send(.view(.pagenation)) + } + + @Test("TC-28: hasNext가 false이면 pagination 요청이 무시된다") + func TC28_hasNext가_false이면_pagination이_무시된다() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue() + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_unread], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.notifications = .init( + data: [.featureSetting_unread], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + + /// hasNext == false → pagenation 액션 전송해도 async 액션 발생하지 않음 + await store.send(.view(.pagenation)) + } + + // MARK: - TC-30: 알림함 재진입 → 목록 새로고침 (읽음 API는 미호출) + + @Test("TC-30: 알림함 재진입 시 목록을 다시 조회하되 읽음 API는 호출하지 않는다") + func TC30_재진입시_목록_새로고침_읽음API_미호출() async throws { + var fetchCount = 0 + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { id in + preconditionFailure("재진입만으로 읽음 처리되면 안 됩니다: \(id)") + } + ) + $0[NotificationClient.self].알림_목록_조회 = { _ in + fetchCount += 1 + return .featureSetting_firstPageResponse + } + $0[PasteboardClient.self] = .noop + } + + /// 첫 번째 진입 + await store.send(.view(.뷰가_나타났을때)) + await store.receive(\.async.뷰가_나타났을때_알람_목록_조회_API) + await store.receive(\.async.클립보드_감지) + await store.receive(\.inner.뷰가_나타났을때_알람_목록_조회_API_반영) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: featureSetting_defaultSort, + hasNext: true + ) + $0.isLoading = false + } + + /// 두 번째 진입 (재진입) — 상태가 동일하므로 trailing closure 생략 + await store.send(.view(.뷰가_나타났을때)) + await store.receive(\.async.뷰가_나타났을때_알람_목록_조회_API) + await store.receive(\.async.클립보드_감지) + await store.receive(\.inner.뷰가_나타났을때_알람_목록_조회_API_반영) + + /// 알림_목록_조회가 두 번 호출되었는지 확인 + guard fetchCount == 2 else { + preconditionFailure("재진입 시 목록 조회가 2번 호출되어야 합니다. 실제: \(fetchCount)") + } + } + + // MARK: - TC-32: 특정 알림 클릭 → 읽음 처리 API + + @Test("TC-32: 안읽은 알림을 클릭하면 읽음 처리 API가 호출되고 isRead가 true로 변경된다") + func TC32_안읽음_알림_클릭시_읽음처리_API_호출() async throws { + var readCalledWithId: Int? + let routeRecorder = RouteRecorder() + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { id in + readCalledWithId = id + } + ) + $0[DeeplinkRouteClient.self].routeTo = { url in + await routeRecorder.append(url) + } + } + + /// 데이터 세팅 + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + + /// 안읽은 알림(id=1) 클릭 + let task = await store.send(.view(.알람_항목_선택했을때(item: .featureSetting_unread))) + await store.receive(\.async.알람_읽음_API) + await store.receive(\.inner.알람_읽음_API_반영) { + $0.notifications.data[0].isRead = true + } + await task.finish() + + /// 읽음 API가 정확한 ID로 호출되었는지 확인 + guard readCalledWithId == 1 else { + preconditionFailure("읽음 API가 id=1로 호출되어야 합니다. 실제: \(String(describing: readCalledWithId))") + } + } + + @Test("TC-32: 이미 읽은 알림을 클릭하면 읽음 API를 호출하지 않는다") + func TC32_이미_읽은_알림_클릭시_읽음API_미호출() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { id in + preconditionFailure("이미 읽은 알림에서 읽음 API가 호출되면 안 됩니다: \(id)") + } + ) + $0[DeeplinkRouteClient.self].routeTo = { url in + guard url?.absoluteString == "pokit://shared?categoryId=10&userId=3" else { + preconditionFailure("예상하지 못한 deeplink 입니다: \(url?.absoluteString ?? "nil")") + } + } + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + let task = await store.send(.view(.알람_항목_선택했을때(item: .featureSetting_read))) + await task.finish() + } + + // MARK: - TC-35: 알림 클릭 → 딥링크별 페이지 이동 + + @Test("TC-35: CONTENT_DETAIL 딥링크 알림 클릭 시 해당 URL로 라우팅된다") + func TC35_CONTENT_DETAIL_딥링크_라우팅() async throws { + let routeRecorder = RouteRecorder() + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { _ in } + ) + $0[DeeplinkRouteClient.self].routeTo = { url in + await routeRecorder.append(url) + } + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_contentDetailDeeplink], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.notifications = .init( + data: [.featureSetting_contentDetailDeeplink], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + + let task = await store.send(.view(.알람_항목_선택했을때(item: .featureSetting_contentDetailDeeplink))) + await store.receive(\.async.알람_읽음_API) + await store.receive(\.inner.알람_읽음_API_반영) { + $0.notifications.data[0].isRead = true + } + await task.finish() + + let routes = await routeRecorder.values() + guard routes == ["pokit://content-detail?contentId=99"] else { + preconditionFailure("CONTENT_DETAIL 라우팅 결과가 예상과 다릅니다: \(routes)") + } + } + + @Test("TC-35: USER_LIST 딥링크 알림 클릭 시 해당 URL로 라우팅된다") + func TC35_USER_LIST_딥링크_라우팅() async throws { + let routeRecorder = RouteRecorder() + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { _ in } + ) + $0[DeeplinkRouteClient.self].routeTo = { url in + await routeRecorder.append(url) + } + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_userListDeeplink], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.notifications = .init( + data: [.featureSetting_userListDeeplink], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + + let task = await store.send(.view(.알람_항목_선택했을때(item: .featureSetting_userListDeeplink))) + await store.receive(\.async.알람_읽음_API) + await store.receive(\.inner.알람_읽음_API_반영) { + $0.notifications.data[0].isRead = true + } + await task.finish() + + let routes = await routeRecorder.values() + guard routes == ["pokit://shared?categoryId=20&userId=5"] else { + preconditionFailure("USER_LIST 라우팅 결과가 예상과 다릅니다: \(routes)") + } + } + + @Test("TC-35: deepLink가 nil인 알림 클릭 시 라우팅하지 않는다") + func TC35_deepLink가_nil이면_라우팅하지않는다() async throws { + let routeRecorder = RouteRecorder() + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { _ in } + ) + $0[DeeplinkRouteClient.self].routeTo = { url in + await routeRecorder.append(url) + } + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_noDeeplink], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.notifications = .init( + data: [.featureSetting_noDeeplink], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + + let task = await store.send(.view(.알람_항목_선택했을때(item: .featureSetting_noDeeplink))) + await store.receive(\.async.알람_읽음_API) + await store.receive(\.inner.알람_읽음_API_반영) { + $0.notifications.data[0].isRead = true + } + await task.finish() + + let routes = await routeRecorder.values() + guard routes.isEmpty else { + preconditionFailure("deepLink가 nil일 때 라우팅이 발생하면 안 됩니다: \(routes)") + } + } + + // MARK: - TC-29, TC-33, TC-34, TC-36~40: Manual QA Required + + // TC-29: 안 읽은 알림 배경색 (orange._50) → UI 렌더링 테스트로 TestStore로 검증 불가, 수동 QA 필요 + // TC-33: 앱이 foreground일 때 push 수신 무시 → 시스템 레벨 푸시 테스트로 TestStore 범위 밖 + // TC-34: 앱이 background → foreground 전환 시 알림 갱신 → 앱 생명주기 테스트, 수동 QA 필요 + // TC-36: 알림 설정 on/off → 시스템 설정 연동, 수동 QA 필요 + // TC-37: 알림 권한 미허용 시 알림 미수신 → 시스템 권한 테스트, 수동 QA 필요 + // TC-38: 로그아웃 후 알림 미수신 → 인증 상태 연동, 수동 QA 필요 + // TC-39: 알림 탭 후 앱 미설치 시 스토어 이동 → 딥링크 fallback, 수동 QA 필요 + // TC-40: 알림 수신 시 뱃지 표시 → 시스템 뱃지 연동, 수동 QA 필요 + + // MARK: - 기존 보조 테스트 + + @Test("알림함 진입만으로는 읽음 API를 호출하지 않는다") + func 알림함_진입만으로는_읽음API를_호출하지않는다() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { id in + preconditionFailure("알림함 진입만으로 읽음 처리되면 안 됩니다: \(id)") + } + ) + $0[PasteboardClient.self] = .noop + } + + await store.send(.view(.뷰가_나타났을때)) + await store.receive(\.async.뷰가_나타났을때_알람_목록_조회_API) + await store.receive(\.async.클립보드_감지) + await store.receive(\.inner.뷰가_나타났을때_알람_목록_조회_API_반영) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: featureSetting_defaultSort, + hasNext: true + ) + $0.isLoading = false + } + } + + @Test("pagination은 추가 페이지를 이어붙인다") + func pagination은_추가페이지를_이어붙인다() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue() + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: true + )))) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: true + ) + $0.isLoading = false + } + await store.send(.view(.pagenation)) + await store.receive(\.async.pagenation_알람_목록_조회_API) + await store.receive(\.inner.pagenation_알람_목록_조회_API_반영) { + $0.notifications = .init( + data: [ + .featureSetting_unread, + .featureSetting_read, + .featureSetting_pagination + ], + page: 1, + size: 10, + sort: featureSetting_defaultSort, + hasNext: false + ) + guard $0.alertContents?[id: 3]?.title == "'공유 포킷' 포킷 사용이 제한되었어요" else { + preconditionFailure("페이지네이션된 알림 문구가 예상과 다릅니다: \($0.alertContents?.elements ?? [])") + } + } + } + + @Test("pagination만으로는 읽음 API를 호출하지 않는다") + func pagination만으로는_읽음API를_호출하지않는다() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onRead: { id in + preconditionFailure("페이지네이션만으로 읽음 처리되면 안 됩니다: \(id)") + } + ) + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: true + )))) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: true + ) + $0.isLoading = false + } + await store.send(.view(.pagenation)) + await store.receive(\.async.pagenation_알람_목록_조회_API) + await store.receive(\.inner.pagenation_알람_목록_조회_API_반영) { + $0.notifications = .init( + data: [ + .featureSetting_unread, + .featureSetting_read, + .featureSetting_pagination + ], + page: 1, + size: 10, + sort: featureSetting_defaultSort, + hasNext: false + ) + } + } + + @Test("밀어서 삭제하면 목록에서 제거된다") + func 밀어서_삭제하면_목록에서_제거된다() async throws { + let store = TestStore(initialState: PokitAlertBoxFeature.State()) { + PokitAlertBoxFeature() + } withDependencies: { + $0[NotificationClient.self] = .featureSettingTestValue( + onDelete: { id in + guard id == 1 else { + preconditionFailure("예상하지 못한 삭제 알림 ID입니다: \(id)") + } + } + ) + } + + await store.send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(.init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: false + )))) { + $0.notifications = .init( + data: [.featureSetting_unread, .featureSetting_read], + page: 0, + size: 10, + sort: [], + hasNext: false + ) + $0.isLoading = false + } + await store.send(.view(.밀어서_삭제했을때(item: .featureSetting_unread))) + await store.receive(\.async.알람_삭제_API) + await store.receive(\.inner.알람_삭제_API_반영) { + $0.notifications.data = [.featureSetting_read] + } + } +} diff --git a/Projects/SharedThirdPartyLib/Project.swift b/Projects/SharedThirdPartyLib/Project.swift index a362e892..3361529b 100644 --- a/Projects/SharedThirdPartyLib/Project.swift +++ b/Projects/SharedThirdPartyLib/Project.swift @@ -22,7 +22,8 @@ let project = Project( sources: ["Sources/**"], dependencies: [ // TODO: 의존성 추가 - .external(name: "ComposableArchitecture") + .external(name: "ComposableArchitecture"), + .external(name: "SchemeRoute") ], settings: .settings() ) diff --git a/Projects/Util/Sources/Constants.swift b/Projects/Util/Sources/Constants.swift index 8bb76877..dadbbef7 100644 --- a/Projects/Util/Sources/Constants.swift +++ b/Projects/Util/Sources/Constants.swift @@ -14,6 +14,7 @@ public enum Constants { public static let categoryPath: String = "/api/v1/category" public static let categoryPathV2: String = "/api/v2/category" public static let contentPath: String = "/api/v1/content" + public static let notificationPath: String = "/api/v2/notifications" public static let remindPath: String = "api/v1/remind" public static let alertPath: String = "/api/v1/alert" public static let versionPath: String = "/api/v1/version" diff --git a/Projects/Util/Sources/Protocols/PokitLinkCardItem.swift b/Projects/Util/Sources/Protocols/PokitLinkCardItem.swift index 4f65156e..40fddec2 100644 --- a/Projects/Util/Sources/Protocols/PokitLinkCardItem.swift +++ b/Projects/Util/Sources/Protocols/PokitLinkCardItem.swift @@ -17,4 +17,7 @@ public protocol PokitLinkCardItem { var data: String { get } var domain: String { get } var isFavorite: Bool? { get } + var authorUserId: Int? { get } + var authorNickname: String? { get } + var authorProfileImageURL: String? { get } } diff --git a/Tuist/Package.swift b/Tuist/Package.swift index f6dc4df2..b4cb47e8 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -22,7 +22,7 @@ let package = Package( // Add your own dependencies here: // .package(url: "https://github.com/Alamofire/Alamofire", from: "5.0.0"), // You can read more about dependencies here: https://docs.tuist.io/documentation/tuist/dependencies - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", "1.10.4" ..< "1.11.1"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", "1.10.4" ..< "1.16.0"), .package(url: "https://github.com/google/GoogleSignIn-iOS", "7.0.0" ..< "7.1.0"), .package(url: "https://github.com/Moya/Moya", from: "15.0.0"), .package(url: "https://github.com/firebase/firebase-ios-sdk", "10.28.0" ..< "10.28.1"), @@ -30,6 +30,9 @@ let package = Package( .package(url: "https://github.com/kean/Nuke", from: "12.8.0"), .package(url: "https://github.com/scinfu/SwiftSoup", "2.7.0" ..< "2.7.5"), .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.22.5"), - .package(url: "https://github.com/amplitude/Amplitude-Swift", from: "1.15.2") + .package(url: "https://github.com/amplitude/Amplitude-Swift", from: "1.15.2"), + .package(url: "https://github.com/ShapeKim98/SchemeRoute", "0.3.1" ..< "0.4.0"), + // Tuist 호환성: swift-protobuf traits 미지원 버전으로 제한 + .package(url: "https://github.com/apple/swift-protobuf", "1.20.0" ..< "1.29.0") ] ) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 164214f1..4ee0d8d8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,24 +15,90 @@ default_platform(:ios) +feature_app_identifiers = [ + "com.pokitmons.pokit.Feature.ContentDetailDemo", + "com.pokitmons.pokit.Feature.ContentSettingDemo", + "com.pokitmons.pokit.Feature.CategorySettingDemo", + "com.pokitmons.pokit.Feature.LoginDemo", + "com.pokitmons.pokit.Feature.PokitDemo", + "com.pokitmons.pokit.Feature.CategoryDetailDemo", + "com.pokitmons.pokit.Feature.SettingDemo", + "com.pokitmons.pokit.Feature.ContentListDemo", + "com.pokitmons.pokit.Feature.CategorySharingDemo", + "com.pokitmons.pokit.Feature.ContentCardDemo", + "com.pokitmons.pokit.Feature.IntroDemo", + "com.pokitmons.pokit.Feature.RecommendDemo" +] + +app_identifiers = [ + "com.pokitmons.pokit", + "com.pokitmons.pokit.ShareExtension" +] + feature_app_identifiers + +test_app_identifiers = [ + "com.pokitmons.pokit.AppTests", + "com.pokitmons.pokit.CoreKitTests", + "com.pokitmons.pokit.Feature.ContentDetailTests", + "com.pokitmons.pokit.Feature.ContentSettingTests", + "com.pokitmons.pokit.Feature.CategorySettingTests", + "com.pokitmons.pokit.Feature.LoginTests", + "com.pokitmons.pokit.Feature.PokitTests", + "com.pokitmons.pokit.Feature.CategoryDetailTests", + "com.pokitmons.pokit.Feature.SettingTests", + "com.pokitmons.pokit.Feature.ContentListTests", + "com.pokitmons.pokit.Feature.CategorySharingTests", + "com.pokitmons.pokit.Feature.ContentCardTests", + "com.pokitmons.pokit.Feature.IntroTests", + "com.pokitmons.pokit.Feature.RecommendTests", + "com.pokitmons.pokit.AppUITests", + "com.pokitmons.pokit.AppUITests.xctrunner" +] + platform :ios do + private_lane :ensure_test_app_identifiers do + require 'spaceship' + + username = ENV['FASTLANE_USER'] || 'shapekim98@gmail.com' + UI.message('Ensuring test bundle identifiers exist on Apple Developer Portal...') + + Spaceship.login(username, nil) + Spaceship.select_team + + test_app_identifiers.each do |bundle_id| + if Spaceship.app.find(bundle_id) + UI.success("[DevCenter] App '#{bundle_id}' already exists") + next + end + + UI.message("[DevCenter] Creating App ID '#{bundle_id}'") + Spaceship.app.create!( + bundle_id: bundle_id, + name: bundle_id, + enable_services: {} + ) + UI.success("[DevCenter] Created App ID '#{bundle_id}'") + end + end + lane :appstore_profile do setup_ci + ensure_test_app_identifiers match( type: "appstore", - app_identifier:["com.pokitmons.pokit", "com.pokitmons.pokit.ShareExtension"], - readonly: true + app_identifier: app_identifiers + test_app_identifiers, + readonly: false ) end lane :development_profile do setup_ci + ensure_test_app_identifiers match( type: "development", - app_identifier:["com.pokitmons.pokit", "com.pokitmons.pokit.ShareExtension"], - readonly: true + app_identifier: app_identifiers + test_app_identifiers, + readonly: false ) end diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 6648ebc2..a09360cc 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -4,7 +4,38 @@ storage_mode("git") type("development") # The default type, can be: appstore, adhoc, enterprise or development -app_identifier(["com.pokitmons.pokit"]) +app_identifier([ + "com.pokitmons.pokit", + "com.pokitmons.pokit.ShareExtension", + "com.pokitmons.pokit.Feature.ContentDetailDemo", + "com.pokitmons.pokit.Feature.ContentSettingDemo", + "com.pokitmons.pokit.Feature.CategorySettingDemo", + "com.pokitmons.pokit.Feature.LoginDemo", + "com.pokitmons.pokit.Feature.PokitDemo", + "com.pokitmons.pokit.Feature.CategoryDetailDemo", + "com.pokitmons.pokit.Feature.SettingDemo", + "com.pokitmons.pokit.Feature.ContentListDemo", + "com.pokitmons.pokit.Feature.CategorySharingDemo", + "com.pokitmons.pokit.Feature.ContentCardDemo", + "com.pokitmons.pokit.Feature.IntroDemo", + "com.pokitmons.pokit.Feature.RecommendDemo", + "com.pokitmons.pokit.AppTests", + "com.pokitmons.pokit.CoreKitTests", + "com.pokitmons.pokit.Feature.ContentDetailTests", + "com.pokitmons.pokit.Feature.ContentSettingTests", + "com.pokitmons.pokit.Feature.CategorySettingTests", + "com.pokitmons.pokit.Feature.LoginTests", + "com.pokitmons.pokit.Feature.PokitTests", + "com.pokitmons.pokit.Feature.CategoryDetailTests", + "com.pokitmons.pokit.Feature.SettingTests", + "com.pokitmons.pokit.Feature.ContentListTests", + "com.pokitmons.pokit.Feature.CategorySharingTests", + "com.pokitmons.pokit.Feature.ContentCardTests", + "com.pokitmons.pokit.Feature.IntroTests", + "com.pokitmons.pokit.Feature.RecommendTests", + "com.pokitmons.pokit.AppUITests", + "com.pokitmons.pokit.AppUITests.xctrunner" +]) username("shapekim98@gmail.com") # Your Apple Developer Portal username # For all available options run `fastlane match --help` diff --git a/fastlane/report.xml b/fastlane/report.xml index 9d5a5bf3..da71ff29 100644 --- a/fastlane/report.xml +++ b/fastlane/report.xml @@ -5,17 +5,22 @@ - + - + - + + + + + + diff --git a/graph.png b/graph.png index 792d0f33..857898a3 100644 Binary files a/graph.png and b/graph.png differ