From 75fa46cfe459da4ebf31b360bfa0b06c075e8f6e Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 28 Jul 2026 14:26:36 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20PushNotificationList=20refresh?= =?UTF-8?q?=EC=99=80=20listener=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListFeature.swift | 49 +++++++------------ .../PushNotificationListView.swift | 15 +++++- .../PushNotificationListViewCoordinator.swift | 12 ++++- .../PushNotificationListFeatureTests.swift | 28 +++++++++++ .../PushNotificationListTestSupport.swift | 24 ++++++++- 5 files changed, 94 insertions(+), 34 deletions(-) diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift index 35b2249d..caf87f70 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift @@ -58,6 +58,8 @@ struct PushNotificationListFeature { case loading(LoadingFeature.Action) enum ViewAction: Equatable { + case stopObserving + case startObserving case refresh case fetchNotifications case loadNextPage @@ -151,23 +153,28 @@ private extension PushNotificationListFeature { state: inout State ) -> Effect { switch action { + case .stopObserving: + return .cancel(id: CancelID.observeNotifications) + case .startObserving: + return observeNotificationsEffect( + query: state.query, + limit: state.query.pageSize + ) case .refresh: state.nextCursor = nil - return fetchNotificationsEffect( + return fetchNotificationsPageEffect( query: state.query, cursor: nil, - existingCount: 0, showsIndicator: false ) case .fetchNotifications: state.nextCursor = nil - return fetchNotificationsEffect(query: state.query, cursor: nil, existingCount: 0) + return fetchNotificationsPageEffect(query: state.query, cursor: nil) case .loadNextPage: guard state.hasMore, !state.isLoading else { return .none } - return fetchNotificationsEffect( + return fetchNotificationsPageEffect( query: state.query, - cursor: state.nextCursor, - existingCount: state.notifications.count + cursor: state.nextCursor ) case .deleteNotification(let item): guard state.notifications.contains(where: { $0.id == item.id }) else { return .none } @@ -268,32 +275,12 @@ private extension PushNotificationListFeature { func refreshForQueryChangeEffect(query: PushNotificationQuery) -> Effect { .merge( updateQueryEffect(query: query), - fetchNotificationsEffect(query: query, cursor: nil, existingCount: 0) - ) - } - - func fetchNotificationsEffect( - query: PushNotificationQuery, - cursor: PushNotificationCursor?, - existingCount: Int, - showsIndicator: Bool = true - ) -> Effect { - let limit = max(query.pageSize, existingCount) - let fetchEffect = fetchNotificationsPageEffect(query: query, cursor: cursor, showsIndicator: showsIndicator) - let observeEffect = observeNotificationsEffect( - query: query, - limit: max(limit, existingCount + query.pageSize) - ) - - if cursor == nil { - return .concatenate( - .cancel(id: CancelID.observeNotifications), - fetchEffect, - observeEffect + .concatenate( + .send(.view(.stopObserving)), + fetchNotificationsPageEffect(query: query, cursor: nil), + .send(.view(.startObserving)) ) - } - - return fetchEffect + ) } func fetchNotificationsPageEffect( diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift index f076c2f3..7d3a889e 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift @@ -38,7 +38,7 @@ public struct PushNotificationListView: View { headerOffset = max(0, -offset) } .safeAreaInset(edge: .top) { safeAreaHeader } - .refreshable { await store.send(.view(.refresh)).finish() } + .refreshableFromiOS18 { await store.send(.view(.refresh)).finish() } .navigationTitle(String(localized: "nav_push_notifications")) .listStyle(.plain) } @@ -396,3 +396,16 @@ public struct PushNotificationListView: View { ) } } + +private extension View { + @ViewBuilder + func refreshableFromiOS18( + action: @escaping @Sendable () async -> Void + ) -> some View { + if #available(iOS 18.0, *) { + refreshable(action: action) + } else { + self + } + } +} diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListViewCoordinator.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListViewCoordinator.swift index 796f505d..7fe59e27 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListViewCoordinator.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListViewCoordinator.swift @@ -17,6 +17,8 @@ public final class PushNotificationListViewCoordinator { private let container: DIContainer @ObservationIgnored private var todoDetailStore: StoreOf? + @ObservationIgnored + private var fetchNotificationsTask: Task? public init(container: DIContainer) { self.container = container @@ -42,7 +44,15 @@ public final class PushNotificationListViewCoordinator { } public func fetchData() { - store.send(.view(.fetchNotifications)) + fetchNotificationsTask?.cancel() + store.send(.view(.stopObserving)) + let query = store.query + let task = store.send(.view(.fetchNotifications)) + fetchNotificationsTask = Task { [store] in + await task.finish() + guard !Task.isCancelled, store.query == query else { return } + store.send(.view(.startObserving)) + } } public func makeTodoDetailStore(todoId: String) -> StoreOf { diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift index 998baccf..2e842b77 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -5,12 +5,40 @@ // Created by opfic on 6/12/26. // +import Combine import Testing import Domain @testable import NotificationTab @MainActor struct PushNotificationListFeatureTests { + @Test("refresh는 listener와 분리되어 첫 페이지 조회 후 끝난다") + func refresh는_listener와_분리되어_첫_페이지_조회_후_끝난다() async { + let subject = PassthroughSubject() + let notification = makePushNotification(id: "observed", number: 1) + let fetchSpy = PushNotificationListFetchUseCaseSpy( + pages: [PushNotificationPage(items: [], nextCursor: nil)], + observePublisher: subject.eraseToAnyPublisher() + ) + let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.startObserving() + subject.send(PushNotificationPage(items: [notification], nextCursor: nil)) + await waitUntilMainActor { + adapter.notifications.first?.id == notification.id + } + + await adapter.refresh() + + #expect(fetchSpy.queries == [.default]) + #expect(fetchSpy.cursors == [nil]) + #expect(fetchSpy.observedQueries == [.default]) + #expect(fetchSpy.observedLimits == [adapter.query.pageSize]) + + subject.send(completion: .finished) + await adapter.finishEffects() + } + @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다") func fetchNotifications는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async throws { let cursor = makePushNotificationCursor(documentID: "cursor-1") diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift index e1da0f5f..39b32570 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -22,6 +22,8 @@ protocol PushNotificationListStateDriving { var selectedTodoId: TodoIdItem? { get } var appliedFilterCount: Int { get } + func startObserving() async + func refresh() async func fetchNotifications() async func loadNextPage() async func toggleSortOption() async @@ -74,6 +76,17 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { store.exhaustivity = .off(showSkippedAssertions: false) } + func startObserving() async { + await store.send(.view(.startObserving)) + await drainReceivedActions() + } + + func refresh() async { + let task = await store.send(.view(.refresh)) + await drainReceivedActions() + await task.finish() + } + func fetchNotifications() async { await store.send(.view(.fetchNotifications)) await drainReceivedActions() @@ -137,6 +150,11 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { await store.send(.sheet(.dismiss)) } + func finishEffects() async { + await drainReceivedActions() + await store.finish() + } + private func presentDeleteNotificationToast(_ notificationId: String) { ToastPresenter.present( message: String(localized: "common_undo"), @@ -171,6 +189,8 @@ final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase { var observePublisher: AnyPublisher private(set) var queries = [PushNotificationQuery]() private(set) var cursors = [PushNotificationCursor?]() + private(set) var observedQueries = [PushNotificationQuery]() + private(set) var observedLimits = [Int]() init( pages: [PushNotificationPage] = [PushNotificationPage(items: [], nextCursor: nil)], @@ -202,6 +222,8 @@ final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase { _ query: PushNotificationQuery, limit: Int ) throws -> AnyPublisher { - observePublisher + observedQueries.append(query) + observedLimits.append(limit) + return observePublisher } } From 4ea39418cf9e4cd2cbc30fcaba1370b62a4fb9f8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 28 Jul 2026 14:54:04 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20refreshable=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20=EC=A1=B0=EA=B1=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListView.swift | 17 ++-------- .../PushNotification/View+Refreshable.swift | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 Application/Presentation/NotificationTab/Sources/PushNotification/View+Refreshable.swift diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift index 7d3a889e..1613c88c 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift @@ -38,7 +38,9 @@ public struct PushNotificationListView: View { headerOffset = max(0, -offset) } .safeAreaInset(edge: .top) { safeAreaHeader } - .refreshableFromiOS18 { await store.send(.view(.refresh)).finish() } + .refreshable(isEnabled: PullToRefreshAvailability.isEnabled) { + await store.send(.view(.refresh)).finish() + } .navigationTitle(String(localized: "nav_push_notifications")) .listStyle(.plain) } @@ -396,16 +398,3 @@ public struct PushNotificationListView: View { ) } } - -private extension View { - @ViewBuilder - func refreshableFromiOS18( - action: @escaping @Sendable () async -> Void - ) -> some View { - if #available(iOS 18.0, *) { - refreshable(action: action) - } else { - self - } - } -} diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/View+Refreshable.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/View+Refreshable.swift new file mode 100644 index 00000000..fb7d62f6 --- /dev/null +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/View+Refreshable.swift @@ -0,0 +1,32 @@ +// +// View+Refreshable.swift +// NotificationTab +// +// Created by opfic on 7/28/26. +// + +import SwiftUI + +enum PullToRefreshAvailability { + static var isEnabled: Bool { + if #available(iOS 18.0, *) { + return true + } else { + return false + } + } +} + +extension View { + @ViewBuilder + func refreshable( + isEnabled: Bool, + action: @escaping @Sendable () async -> Void + ) -> some View { + if isEnabled { + refreshable(action: action) + } else { + self + } + } +} From 19c1e1d3c2b890109950888f30afe9786bb39057 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Jul 2026 00:02:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=8B=9C=EA=B0=84=20=ED=95=84=ED=84=B0=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EC=8B=9C=EA=B0=81=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Sources/PushNotificationQuery.swift | 11 +- .../Service/PushNotificationServiceImpl.swift | 4 +- .../PushNotificationListFeature.swift | 6 + .../PushNotificationListView.swift | 4 +- .../PushNotificationListFeatureTests.swift | 117 +++++++++++++++--- .../PushNotificationListTestAssertions.swift | 35 +++++- .../PushNotificationListTestSupport.swift | 4 + 7 files changed, 153 insertions(+), 28 deletions(-) diff --git a/Application/Core/Sources/PushNotificationQuery.swift b/Application/Core/Sources/PushNotificationQuery.swift index bcfa9c1d..b8222300 100644 --- a/Application/Core/Sources/PushNotificationQuery.swift +++ b/Application/Core/Sources/PushNotificationQuery.swift @@ -23,17 +23,20 @@ public struct PushNotificationQuery: Equatable { public var timeFilter: TimeFilter public var unreadOnly: Bool public var pageSize: Int + public var referenceDate: Date public init( sortOrder: SortOrder, timeFilter: TimeFilter, unreadOnly: Bool, - pageSize: Int + pageSize: Int, + referenceDate: Date = Date() ) { self.sortOrder = sortOrder self.timeFilter = timeFilter self.unreadOnly = unreadOnly self.pageSize = pageSize + self.referenceDate = referenceDate } public static let `default` = PushNotificationQuery( @@ -67,14 +70,14 @@ public extension PushNotificationQuery.TimeFilter { } } - var thresholdDate: Date? { + func thresholdDate(relativeTo referenceDate: Date) -> Date? { switch self { case .none: return nil case .hours(let value): - return Date().addingTimeInterval(-Double(value) * 3600.0) + return referenceDate.addingTimeInterval(-Double(value) * 3600.0) case .days(let value): - return Date().addingTimeInterval(-Double(value) * 86400.0) + return referenceDate.addingTimeInterval(-Double(value) * 86400.0) } } } diff --git a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift index e4744d1c..4cd7b77e 100644 --- a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift +++ b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift @@ -317,7 +317,9 @@ private extension PushNotificationServiceImpl { var firestoreQuery: Query = store.collection(FirestorePath.notifications(uid)) .whereField(PushNotificationFieldKey.isDeleted.rawValue, isEqualTo: false) - if let thresholdDate = query.timeFilter.thresholdDate { + if let thresholdDate = query.timeFilter.thresholdDate( + relativeTo: query.referenceDate + ) { firestoreQuery = firestoreQuery.whereField( "receivedAt", isGreaterThanOrEqualTo: Timestamp(date: thresholdDate) diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift index caf87f70..0a1e776c 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift @@ -100,6 +100,7 @@ struct PushNotificationListFeature { @Dependency(\.undoDeletePushNotificationUseCase) var undoDeletePushNotificationUseCase @Dependency(\.togglePushNotificationReadUseCase) var togglePushNotificationReadUseCase @Dependency(\.updatePushNotificationQueryUseCase) var updatePushNotificationQueryUseCase + @Dependency(\.date.now) var now var body: some ReducerOf { Scope(state: \.loading, action: \.loading) { @@ -118,6 +119,7 @@ struct PushNotificationListFeature { break case .binding(\.query.timeFilter): state.nextCursor = nil + state.query.referenceDate = now return refreshForQueryChangeEffect(query: state.query) case .binding: break @@ -162,6 +164,7 @@ private extension PushNotificationListFeature { ) case .refresh: state.nextCursor = nil + state.query.referenceDate = now return fetchNotificationsPageEffect( query: state.query, cursor: nil, @@ -200,14 +203,17 @@ private extension PushNotificationListFeature { } case .toggleSortOption: state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest + state.query.referenceDate = now state.nextCursor = nil return refreshForQueryChangeEffect(query: state.query) case .toggleUnreadOnly: state.query.unreadOnly.toggle() + state.query.referenceDate = now state.nextCursor = nil return refreshForQueryChangeEffect(query: state.query) case .resetFilters: state.query = .default + state.query.referenceDate = now state.nextCursor = nil return refreshForQueryChangeEffect(query: state.query) case .selectNotification(let notificationId): diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift index 1613c88c..1a8b2ed2 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift @@ -39,7 +39,9 @@ public struct PushNotificationListView: View { } .safeAreaInset(edge: .top) { safeAreaHeader } .refreshable(isEnabled: PullToRefreshAvailability.isEnabled) { - await store.send(.view(.refresh)).finish() + let task = store.send(.view(.refresh)) + store.send(.view(.startObserving)) + await task.finish() } .navigationTitle(String(localized: "nav_push_notifications")) .listStyle(.plain) diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift index 2e842b77..596e9b1e 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -6,39 +6,119 @@ // import Combine +import Foundation import Testing +import Core import Domain @testable import NotificationTab @MainActor struct PushNotificationListFeatureTests { - @Test("refresh는 listener와 분리되어 첫 페이지 조회 후 끝난다") - func refresh는_listener와_분리되어_첫_페이지_조회_후_끝난다() async { + @Test("시간 필터는 query 기준 Date로 threshold를 계산한다") + func 시간_필터는_query_기준_Date로_threshold를_계산한다() { + let referenceDate = Date(timeIntervalSince1970: 100_000) + + #expect( + PushNotificationQuery.TimeFilter.hours(24) + .thresholdDate(relativeTo: referenceDate) + == referenceDate.addingTimeInterval(-86_400) + ) + } + + @Test("refresh는 기준 Date를 갱신한 query로 첫 페이지를 조회한다") + func refresh는_기준_Date를_갱신한_query로_첫_페이지를_조회한다() async { + let referenceDate = Date(timeIntervalSince1970: 1_000) + let now = Date(timeIntervalSince1970: 2_000) + let query = PushNotificationQuery( + sortOrder: .latest, + timeFilter: .hours(24), + unreadOnly: false, + pageSize: 20, + referenceDate: referenceDate + ) + let querySpy = FetchPushNotificationQueryUseCaseSpy() + querySpy.pushNotificationQuery = query + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage(items: [], nextCursor: nil) + ]) + let adapter = PushNotificationListStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchQueryUseCase: querySpy, + now: now + ) + + await adapter.refresh() + + let refreshedQuery = adapter.query + #expect(refreshedQuery.referenceDate == now) + #expect(fetchSpy.queries == [refreshedQuery]) + #expect(fetchSpy.cursors == [nil]) + } + + @Test("refresh 후 listener는 조회와 같은 기준 Date를 사용한다") + func refresh_후_listener는_조회와_같은_기준_Date를_사용한다() async { let subject = PassthroughSubject() - let notification = makePushNotification(id: "observed", number: 1) + let referenceDate = Date(timeIntervalSince1970: 1_000) + let now = Date(timeIntervalSince1970: 2_000) + let query = PushNotificationQuery( + sortOrder: .latest, + timeFilter: .hours(24), + unreadOnly: false, + pageSize: 20, + referenceDate: referenceDate + ) + let querySpy = FetchPushNotificationQueryUseCaseSpy() + querySpy.pushNotificationQuery = query let fetchSpy = PushNotificationListFetchUseCaseSpy( pages: [PushNotificationPage(items: [], nextCursor: nil)], observePublisher: subject.eraseToAnyPublisher() ) - let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy) - - await adapter.startObserving() - subject.send(PushNotificationPage(items: [notification], nextCursor: nil)) - await waitUntilMainActor { - adapter.notifications.first?.id == notification.id - } + let adapter = PushNotificationListStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchQueryUseCase: querySpy, + now: now + ) await adapter.refresh() + await adapter.startObserving() - #expect(fetchSpy.queries == [.default]) - #expect(fetchSpy.cursors == [nil]) - #expect(fetchSpy.observedQueries == [.default]) - #expect(fetchSpy.observedLimits == [adapter.query.pageSize]) + let refreshedQuery = adapter.query + #expect(fetchSpy.queries == [refreshedQuery]) + #expect(fetchSpy.observedQueries == [refreshedQuery]) + #expect(fetchSpy.observedLimits == [refreshedQuery.pageSize]) subject.send(completion: .finished) await adapter.finishEffects() } + @Test("refresh 실패와 관계없이 기준 Date를 갱신한다") + func refresh_실패와_관계없이_기준_Date를_갱신한다() async { + struct DummyError: Error {} + + let referenceDate = Date(timeIntervalSince1970: 1_000) + let now = Date(timeIntervalSince1970: 2_000) + let query = PushNotificationQuery( + sortOrder: .latest, + timeFilter: .hours(24), + unreadOnly: false, + pageSize: 20, + referenceDate: referenceDate + ) + let querySpy = FetchPushNotificationQueryUseCaseSpy() + querySpy.pushNotificationQuery = query + let fetchSpy = PushNotificationListFetchUseCaseSpy() + fetchSpy.error = DummyError() + let adapter = PushNotificationListStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchQueryUseCase: querySpy, + now: now + ) + + await adapter.refresh() + + #expect(adapter.query.referenceDate == now) + } + @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다") func fetchNotifications는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async throws { let cursor = makePushNotificationCursor(documentID: "cursor-1") @@ -75,12 +155,17 @@ struct PushNotificationListFeatureTests { @Test("필터 액션은 query와 적용 필터 수를 갱신한다") func 필터_액션은_query와_적용_필터_수를_갱신한다() async throws { + let now = Date(timeIntervalSince1970: 2_000) let updateSpy = UpdatePushNotificationQueryUseCaseSpy() - let adapter = PushNotificationListStoreTestAdapter(updateQueryUseCase: updateSpy) + let adapter = PushNotificationListStoreTestAdapter( + updateQueryUseCase: updateSpy, + now: now + ) try await verifyFilterStateTransitions( adapter: adapter, - updateQueryUseCaseSpy: updateSpy + updateQueryUseCaseSpy: updateSpy, + referenceDate: now ) } diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift index 75ff8ac3..e0cb2fce 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift @@ -77,7 +77,8 @@ func verifyLoadNextPage( @MainActor func verifyFilterStateTransitions( adapter: Adapter, - updateQueryUseCaseSpy: UpdatePushNotificationQueryUseCaseSpy + updateQueryUseCaseSpy: UpdatePushNotificationQueryUseCaseSpy, + referenceDate: Date ) async throws { await adapter.toggleSortOption() await adapter.setTimeFilter(.hours(24)) @@ -90,18 +91,40 @@ func verifyFilterStateTransitions( #expect(query.unreadOnly) #expect(appliedFilterCount == 3) #expect(updateQueryUseCaseSpy.queries == [ - PushNotificationQuery(sortOrder: .oldest, timeFilter: .none, unreadOnly: false, pageSize: 20), - PushNotificationQuery(sortOrder: .oldest, timeFilter: .hours(24), unreadOnly: false, pageSize: 20), - PushNotificationQuery(sortOrder: .oldest, timeFilter: .hours(24), unreadOnly: true, pageSize: 20) + PushNotificationQuery( + sortOrder: .oldest, + timeFilter: .none, + unreadOnly: false, + pageSize: 20, + referenceDate: referenceDate + ), + PushNotificationQuery( + sortOrder: .oldest, + timeFilter: .hours(24), + unreadOnly: false, + pageSize: 20, + referenceDate: referenceDate + ), + PushNotificationQuery( + sortOrder: .oldest, + timeFilter: .hours(24), + unreadOnly: true, + pageSize: 20, + referenceDate: referenceDate + ) ]) await adapter.resetFilters() let resetQuery = adapter.query let resetAppliedFilterCount = adapter.appliedFilterCount - #expect(resetQuery == .default) + #expect(resetQuery.sortOrder == .latest) + #expect(resetQuery.timeFilter == .none) + #expect(!resetQuery.unreadOnly) + #expect(resetQuery.pageSize == 20) + #expect(resetQuery.referenceDate == referenceDate) #expect(resetAppliedFilterCount == 0) - #expect(updateQueryUseCaseSpy.queries.last == .default) + #expect(updateQueryUseCaseSpy.queries.last == resetQuery) } @MainActor diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift index 39b32570..75202019 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -56,6 +56,7 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { toggleReadUseCase: TogglePushNotificationReadUseCase = TogglePushNotificationReadUseCaseSpy(), fetchQueryUseCase: FetchPushNotificationQueryUseCase = FetchPushNotificationQueryUseCaseSpy(), updateQueryUseCase: UpdatePushNotificationQueryUseCase = UpdatePushNotificationQueryUseCaseSpy(), + now: Date? = nil, configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { store = TestStore( @@ -71,6 +72,9 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { $0.togglePushNotificationReadUseCase = toggleReadUseCase $0.updatePushNotificationQueryUseCase = updateQueryUseCase $0.continuousClock = ContinuousClock() + if let now { + $0.date.now = now + } configureDependencies?(&$0) } store.exhaustivity = .off(showSkippedAssertions: false) From ff86d6ce6cd653690a650db2e7f04a93bf0c575a Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Jul 2026 01:03:02 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=8B=A4=EC=9D=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=8C=90=EB=B3=84=20=EC=B1=85=EC=9E=84=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/PushNotificationServiceImpl.swift | 43 ++++++++----------- .../PushNotificationListFeature.swift | 15 ++----- .../PushNotificationListView.swift | 2 +- .../PushNotificationListFeatureTests.swift | 20 ++++++++- .../PushNotificationListTestAssertions.swift | 6 +-- .../PushNotificationListTestSupport.swift | 4 +- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift index 4cd7b77e..aa513e20 100644 --- a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift +++ b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift @@ -135,25 +135,12 @@ final class PushNotificationServiceImpl: PushNotificationService { ]) } + let pageLimit = notificationQuery.pageSize let snapshot = try await firestoreQuery - .limit(to: notificationQuery.pageSize) + .limit(to: pageLimit + 1) .getDocuments() - let items = snapshot.documents.compactMap { makeResponse(from: $0) } - - let nextCursor: PushNotificationCursorDTO? = snapshot.documents.last.map { document in - guard let receivedAt = document.data()[PushNotificationFieldKey.receivedAt.rawValue] as? Timestamp - else { - return nil - } - - return PushNotificationCursorDTO( - receivedAt: receivedAt.dateValue(), - documentID: document.documentID - ) - } ?? nil - - return PushNotificationPageResponse(items: items, nextCursor: nextCursor) + return makePageResponse(from: snapshot.documents, limit: pageLimit) } catch { logger.error("Failed to request notifications", error: error) record(error, code: .requestNotifications) @@ -170,7 +157,7 @@ final class PushNotificationServiceImpl: PushNotificationService { let subject = PassthroughSubject() let pageLimit = max(query.pageSize, limit) let listener = makeQuery(uid: uid, query: query) - .limit(to: pageLimit) + .limit(to: pageLimit + 1) .addSnapshotListener { [weak self] snapshot, error in if let error { Self.record(error, code: .observeNotifications) @@ -180,14 +167,7 @@ final class PushNotificationServiceImpl: PushNotificationService { guard let self, let snapshot else { return } - let items = snapshot.documents.compactMap { self.makeResponse(from: $0) } - let nextCursor = self.makeNextCursor(from: snapshot.documents.last) - subject.send( - PushNotificationPageResponse( - items: items, - nextCursor: nextCursor - ) - ) + subject.send(self.makePageResponse(from: snapshot.documents, limit: pageLimit)) } return subject @@ -349,6 +329,19 @@ private extension PushNotificationServiceImpl { ) } + func makePageResponse( + from documents: [QueryDocumentSnapshot], + limit: Int + ) -> PushNotificationPageResponse { + let pageDocuments = Array(documents.prefix(limit)) + let items = pageDocuments.compactMap { makeResponse(from: $0) } + let nextCursor = limit < documents.count + ? makeNextCursor(from: pageDocuments.last) + : nil + + return PushNotificationPageResponse(items: items, nextCursor: nextCursor) + } + func makeResponse(from snapshot: QueryDocumentSnapshot) -> PushNotificationResponse? { let data = snapshot.data() if (data[PushNotificationFieldKey.isDeleted.rawValue] as? Bool) == true { diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift index 0a1e776c..517ce806 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift @@ -18,7 +18,6 @@ struct PushNotificationListFeature { @Presents var alert: AlertState? @Presents var sheet: SheetState? var notifications: [PushNotificationItem] = [] - var hasMore = false var nextCursor: PushNotificationCursor? var query: PushNotificationQuery var selectedNotificationId: String? @@ -82,8 +81,7 @@ struct PushNotificationListFeature { case setAlert case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) case resetPagination - case setHasMore(Bool) - case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) + case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) case setNotificationHidden(String, Bool) case setNotificationRead(String, Bool) } @@ -174,7 +172,7 @@ private extension PushNotificationListFeature { state.nextCursor = nil return fetchNotificationsPageEffect(query: state.query, cursor: nil) case .loadNextPage: - guard state.hasMore, !state.isLoading else { return .none } + guard state.nextCursor != nil, !state.isLoading else { return .none } return fetchNotificationsPageEffect( query: state.query, cursor: state.nextCursor @@ -258,15 +256,12 @@ private extension PushNotificationListFeature { case .resetPagination: state.notifications = [] state.nextCursor = nil - case .setHasMore(let value): - state.hasMore = value - case .syncNotifications(let notifications, let nextCursor, let hasMore): + case .syncNotifications(let notifications, let nextCursor): state.notifications = Self.mergedHiddenNotifications( currentNotifications: state.notifications, incomingNotifications: notifications ) state.nextCursor = nextCursor - state.hasMore = hasMore case .setNotificationHidden(let notificationId, let isHidden): Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) case .setNotificationRead(let notificationId, let isRead): @@ -309,7 +304,6 @@ private extension PushNotificationListFeature { nextCursor: page.nextCursor )) ) - await send(.store(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil))) if showsIndicator { await send(.loading(.end(target: .default, mode: .delayed))) } @@ -332,8 +326,7 @@ private extension PushNotificationListFeature { let publisher = try fetchPushNotificationsUseCase.observe(query, limit: limit) for try await page in publisher.values { let items = page.items.map(PushNotificationItem.init(from:)) - let hasMore = items.count == max(query.pageSize, limit) && page.nextCursor != nil - await send(.store(.syncNotifications(items, nextCursor: page.nextCursor, hasMore: hasMore))) + await send(.store(.syncNotifications(items, nextCursor: page.nextCursor))) } } catch is CancellationError { } catch { diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift index 1a8b2ed2..89810c57 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift @@ -128,7 +128,7 @@ public struct PushNotificationListView: View { ) .onAppear { let lastId = notifications.last?.id - if notification.id == lastId, store.hasMore { + if notification.id == lastId, store.nextCursor != nil { store.send(.view(.loadNextPage)) } } diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift index 596e9b1e..165594fd 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -119,8 +119,8 @@ struct PushNotificationListFeatureTests { #expect(adapter.query.referenceDate == now) } - @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다") - func fetchNotifications는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async throws { + @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 nextCursor 상태를 갱신한다") + func fetchNotifications는_첫_페이지를_조회하고_목록과_nextCursor_상태를_갱신한다() async throws { let cursor = makePushNotificationCursor(documentID: "cursor-1") let notifications = (0..<20).map { makePushNotification(id: "notification-\($0)", number: $0, isRead: $0.isMultiple(of: 2)) @@ -153,6 +153,22 @@ struct PushNotificationListFeatureTests { ) } + @Test("nextCursor가 없으면 페이지 크기만큼 조회해도 다음 페이지를 요청하지 않는다") + func nextCursor가_없으면_페이지_크기만큼_조회해도_다음_페이지를_요청하지_않는다() async { + let notifications = (0..<20).map { + makePushNotification(id: "notification-\($0)", number: $0) + } + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage(items: notifications, nextCursor: nil) + ]) + let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.fetchNotifications() + await adapter.loadNextPage() + + #expect(fetchSpy.cursors.count == 1) + } + @Test("필터 액션은 query와 적용 필터 수를 갱신한다") func 필터_액션은_query와_적용_필터_수를_갱신한다() async throws { let now = Date(timeIntervalSince1970: 2_000) diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift index e0cb2fce..dd3860ef 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestAssertions.swift @@ -40,11 +40,10 @@ func verifyFetchNotifications( } let notifications = adapter.notifications - let hasMore = adapter.hasMore #expect(fetchUseCaseSpy.queries.map(\.pageSize) == [20]) #expect(fetchUseCaseSpy.cursors.map { $0?.documentID } == [nil]) #expect(notifications == expectedItems) - #expect(hasMore) + #expect(adapter.nextCursor == fetchUseCaseSpy.pages[0].nextCursor) } @MainActor @@ -68,10 +67,9 @@ func verifyLoadNextPage( } let notifications = adapter.notifications - let hasMore = adapter.hasMore #expect(fetchUseCaseSpy.cursors.map { $0?.documentID } == [nil, nextCursorId]) #expect(notifications.last == PushNotificationItem(from: nextNotification)) - #expect(!hasMore) + #expect(adapter.nextCursor == nil) } @MainActor diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift index 75202019..3d9b9b8a 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -17,7 +17,7 @@ import Foundation protocol PushNotificationListStateDriving { var notifications: [PushNotificationItem] { get } var query: PushNotificationQuery { get } - var hasMore: Bool { get } + var nextCursor: PushNotificationCursor? { get } var selectedNotificationId: String? { get } var selectedTodoId: TodoIdItem? { get } var appliedFilterCount: Int { get } @@ -43,7 +43,7 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { var notifications: [PushNotificationItem] { store.state.notifications } var query: PushNotificationQuery { store.state.query } - var hasMore: Bool { store.state.hasMore } + var nextCursor: PushNotificationCursor? { store.state.nextCursor } var selectedNotificationId: String? { store.state.selectedNotificationId } var selectedTodoId: TodoIdItem? { store.state.selectedTodoId } var appliedFilterCount: Int { store.state.appliedFilterCount } From 752ea93371caabd767068105c84da11e4be60870 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 29 Jul 2026 01:23:14 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20refresh=EC=99=80=20listener=20=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListFeature.swift | 42 ++++++++------- .../PushNotificationListView.swift | 2 +- .../PushNotificationListFeatureTests.swift | 51 +++++++++++++++++++ .../PushNotificationListTestSupport.swift | 32 ++++++++++++ 4 files changed, 108 insertions(+), 19 deletions(-) diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift index 517ce806..96ece740 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListFeature.swift @@ -80,8 +80,7 @@ struct PushNotificationListFeature { enum StoreAction: Equatable { case setAlert case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) - case resetPagination - case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) + case replaceNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) case setNotificationHidden(String, Bool) case setNotificationRead(String, Bool) } @@ -163,10 +162,13 @@ private extension PushNotificationListFeature { case .refresh: state.nextCursor = nil state.query.referenceDate = now - return fetchNotificationsPageEffect( - query: state.query, - cursor: nil, - showsIndicator: false + return .concatenate( + .cancel(id: CancelID.observeNotifications), + fetchNotificationsPageEffect( + query: state.query, + cursor: nil, + showsIndicator: false + ) ) case .fetchNotifications: state.nextCursor = nil @@ -253,10 +255,7 @@ private extension PushNotificationListFeature { incomingNotifications: notifications )) state.nextCursor = nextCursor - case .resetPagination: - state.notifications = [] - state.nextCursor = nil - case .syncNotifications(let notifications, let nextCursor): + case .replaceNotifications(let notifications, let nextCursor): state.notifications = Self.mergedHiddenNotifications( currentNotifications: state.notifications, incomingNotifications: notifications @@ -295,15 +294,22 @@ private extension PushNotificationListFeature { } do { let page = try await fetchPushNotificationsUseCase.execute(query, cursor: cursor) + let notifications = page.items.map(PushNotificationItem.init(from:)) if cursor == nil { - await send(.store(.resetPagination)) + await send( + .store(.replaceNotifications( + notifications, + nextCursor: page.nextCursor + )) + ) + } else { + await send( + .store(.appendNotifications( + notifications, + nextCursor: page.nextCursor + )) + ) } - await send( - .store(.appendNotifications( - page.items.map(PushNotificationItem.init(from:)), - nextCursor: page.nextCursor - )) - ) if showsIndicator { await send(.loading(.end(target: .default, mode: .delayed))) } @@ -326,7 +332,7 @@ private extension PushNotificationListFeature { let publisher = try fetchPushNotificationsUseCase.observe(query, limit: limit) for try await page in publisher.values { let items = page.items.map(PushNotificationItem.init(from:)) - await send(.store(.syncNotifications(items, nextCursor: page.nextCursor))) + await send(.store(.replaceNotifications(items, nextCursor: page.nextCursor))) } } catch is CancellationError { } catch { diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift index 89810c57..b3a95e57 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationListView.swift @@ -40,8 +40,8 @@ public struct PushNotificationListView: View { .safeAreaInset(edge: .top) { safeAreaHeader } .refreshable(isEnabled: PullToRefreshAvailability.isEnabled) { let task = store.send(.view(.refresh)) - store.send(.view(.startObserving)) await task.finish() + store.send(.view(.startObserving)) } .navigationTitle(String(localized: "nav_push_notifications")) .listStyle(.plain) diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift index 165594fd..5bf83d2b 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -119,6 +119,57 @@ struct PushNotificationListFeatureTests { #expect(adapter.query.referenceDate == now) } + @Test("refresh는 listener를 중단하고 조회 완료 후 재구독한 결과를 반영한다") + func refresh는_listener를_중단하고_조회_완료_후_재구독한_결과를_반영한다() async { + let subject = PassthroughSubject() + let cancellationSpy = ObservationCancellationSpy() + let initialNotification = makePushNotification(id: "initial", number: 0) + let refreshingNotification = makePushNotification(id: "refreshing", number: 1) + let fetchedNotification = makePushNotification(id: "fetched", number: 2) + let observedNotification = makePushNotification(id: "observed", number: 3) + let fetchSpy = PushNotificationListFetchUseCaseSpy( + pages: [ + PushNotificationPage(items: [fetchedNotification], nextCursor: nil) + ], + observePublisher: subject + .handleEvents(receiveCancel: cancellationSpy.call) + .eraseToAnyPublisher() + ) + fetchSpy.shouldSuspend = true + let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.startObserving() + subject.send(PushNotificationPage(items: [initialNotification], nextCursor: nil)) + await waitUntilMainActor { + adapter.notifications.map(\.id) == [initialNotification.id] + } + + let task = Task { await adapter.refresh() } + await waitUntilMainActor { + fetchSpy.queries.count == 1 + } + + #expect(cancellationSpy.callCount == 1) + + subject.send(PushNotificationPage(items: [refreshingNotification], nextCursor: nil)) + try? await Task.sleep(for: .milliseconds(50)) + #expect(adapter.notifications.map(\.id) == [initialNotification.id]) + + fetchSpy.resume() + await task.value + + #expect(adapter.notifications.map(\.id) == [fetchedNotification.id]) + + await adapter.startObserving() + subject.send(PushNotificationPage(items: [observedNotification], nextCursor: nil)) + await waitUntilMainActor { + adapter.notifications.map(\.id) == [observedNotification.id] + } + + subject.send(completion: .finished) + await adapter.finishEffects() + } + @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 nextCursor 상태를 갱신한다") func fetchNotifications는_첫_페이지를_조회하고_목록과_nextCursor_상태를_갱신한다() async throws { let cursor = makePushNotificationCursor(documentID: "cursor-1") diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift index 3d9b9b8a..da138051 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -191,10 +191,13 @@ final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase { var pages: [PushNotificationPage] var error: Error? var observePublisher: AnyPublisher + var shouldSuspend = false private(set) var queries = [PushNotificationQuery]() private(set) var cursors = [PushNotificationCursor?]() private(set) var observedQueries = [PushNotificationQuery]() private(set) var observedLimits = [Int]() + private var continuation: CheckedContinuation? + private var shouldResume = false init( pages: [PushNotificationPage] = [PushNotificationPage(items: [], nextCursor: nil)], @@ -211,6 +214,17 @@ final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase { queries.append(query) cursors.append(cursor) + if shouldSuspend { + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } + if let error { throw error } @@ -230,4 +244,22 @@ final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase { observedLimits.append(limit) return observePublisher } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } +} + +final class ObservationCancellationSpy { + private(set) var callCount = 0 + + func call() { + callCount += 1 + } }