diff --git a/Application/DevLogData/Sources/DTO/TodoCategoryPreferenceResponse.swift b/Application/DevLogData/Sources/DTO/TodoCategoryPreferenceResponse.swift index 8bc875a3..1f0e7226 100644 --- a/Application/DevLogData/Sources/DTO/TodoCategoryPreferenceResponse.swift +++ b/Application/DevLogData/Sources/DTO/TodoCategoryPreferenceResponse.swift @@ -7,13 +7,13 @@ import Foundation -public struct TodoCategoryPreferenceResponse: Equatable { - public enum Category: Equatable { +public struct TodoCategoryPreferenceResponse: Equatable, Codable { + public enum Category: Equatable, Codable { case system(String) case user(UserCategory) } - public struct UserCategory: Equatable { + public struct UserCategory: Equatable, Codable { public let id: String public let name: String public let colorHex: String diff --git a/Application/DevLogData/Sources/DataAssembler.swift b/Application/DevLogData/Sources/DataAssembler.swift index bb64b47c..42c7b2bd 100644 --- a/Application/DevLogData/Sources/DataAssembler.swift +++ b/Application/DevLogData/Sources/DataAssembler.swift @@ -40,26 +40,29 @@ public final class DataAssembler: Assembler { TodoRepositoryImpl( todoService: container.resolve(TodoService.self), todoCategoryService: container.resolve(TodoCategoryService.self), + store: container.resolve(MemoryCacheStore.self), widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self), todoMutationEventBus: container.resolve(TodoMutationEventBus.self) ) } container.register(WidgetTodoSnapshotRepository.self) { - WidgetTodoSnapshotRepositoryImpl( - repository: container.resolve(TodoRepository.self) - ) + WidgetTodoSnapshotRepositoryImpl(todoService: container.resolve(TodoService.self)) } container.register(TodoCategoryRepository.self) { TodoCategoryRepositoryImpl( - todoCategoryService: container.resolve(TodoCategoryService.self) + todoCategoryService: container.resolve(TodoCategoryService.self), + store: container.resolve(MemoryCacheStore.self) ) } container.register(AuthSessionRepository.self) { AuthSessionRepositoryImpl( - authService: container.resolve(AuthService.self) + authService: container.resolve(AuthService.self), + todoCategoryService: container.resolve(TodoCategoryService.self), + store: container.resolve(MemoryCacheStore.self), + provider: container.resolve(AuthSessionStateProvider.self) ) } @@ -100,7 +103,8 @@ public final class DataAssembler: Assembler { container.register(PushNotificationRepository.self) { PushNotificationRepositoryImpl( pushNotificationService: container.resolve(PushNotificationService.self), - todoCategoryService: container.resolve(TodoCategoryService.self) + todoCategoryService: container.resolve(TodoCategoryService.self), + store: container.resolve(MemoryCacheStore.self) ) } diff --git a/Application/DevLogData/Sources/Protocol/AuthSessionStateProvider.swift b/Application/DevLogData/Sources/Protocol/AuthSessionStateProvider.swift new file mode 100644 index 00000000..6a6128e5 --- /dev/null +++ b/Application/DevLogData/Sources/Protocol/AuthSessionStateProvider.swift @@ -0,0 +1,13 @@ +// +// AuthSessionStateProvider.swift +// DevLogData +// +// Created by opfic on 6/9/26. +// + +import Combine + +public protocol AuthSessionStateProvider { + func publish(_ isSignedIn: Bool) + func observeSignedIn() -> AnyPublisher +} diff --git a/Application/DevLogData/Sources/Protocol/MemoryCacheStore.swift b/Application/DevLogData/Sources/Protocol/MemoryCacheStore.swift new file mode 100644 index 00000000..06893155 --- /dev/null +++ b/Application/DevLogData/Sources/Protocol/MemoryCacheStore.swift @@ -0,0 +1,13 @@ +// +// MemoryCacheStore.swift +// DevLogData +// +// Created by opfic on 6/9/26. +// + +import Foundation + +public protocol MemoryCacheStore { + func value(forKey key: String) -> T? + func setValue(_ value: T?, forKey key: String) +} diff --git a/Application/DevLogData/Sources/Protocol/TodoCategoryService.swift b/Application/DevLogData/Sources/Protocol/TodoCategoryService.swift index 3ad7e980..466f91ed 100644 --- a/Application/DevLogData/Sources/Protocol/TodoCategoryService.swift +++ b/Application/DevLogData/Sources/Protocol/TodoCategoryService.swift @@ -8,6 +8,6 @@ import Foundation public protocol TodoCategoryService { - func fetchPreferences() async throws -> [TodoCategoryPreferenceResponse] - func updatePreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws + func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] + func updateCategoryPreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws } diff --git a/Application/DevLogData/Sources/Protocol/UserDefaultsStore.swift b/Application/DevLogData/Sources/Protocol/UserDefaultsStore.swift index 15dd37f7..5369c52a 100644 --- a/Application/DevLogData/Sources/Protocol/UserDefaultsStore.swift +++ b/Application/DevLogData/Sources/Protocol/UserDefaultsStore.swift @@ -8,6 +8,9 @@ import Foundation public protocol UserDefaultsStore { + func value(forKey key: String) -> T? + func setValue(_ value: T?, forKey key: String) + func removeValues(withPrefix prefix: String) func string(forKey key: String) -> String? func setString(_ value: String?, forKey key: String) func stringArray(forKey key: String) -> [String] diff --git a/Application/DevLogData/Sources/Repository/AuthSessionRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/AuthSessionRepositoryImpl.swift index f9b154d2..6971c204 100644 --- a/Application/DevLogData/Sources/Repository/AuthSessionRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/AuthSessionRepositoryImpl.swift @@ -9,13 +9,67 @@ import Combine import DevLogDomain final class AuthSessionRepositoryImpl: AuthSessionRepository { + private enum Key { + static let preferences = "TodoCategory.preferences" + } + private let authService: AuthService + private let todoCategoryService: TodoCategoryService + private let store: MemoryCacheStore + private let provider: AuthSessionStateProvider + private var cancellables = Set() - init(authService: AuthService) { + init( + authService: AuthService, + todoCategoryService: TodoCategoryService, + store: MemoryCacheStore, + provider: AuthSessionStateProvider + ) { self.authService = authService + self.todoCategoryService = todoCategoryService + self.store = store + self.provider = provider + + setupObservation() } func observeSignedIn() -> AnyPublisher { + provider.observeSignedIn() + } +} + +private extension AuthSessionRepositoryImpl { + func setupObservation() { authService.observeSignedIn() + .removeDuplicates() + .sink { [weak self] isSignedIn in + Task { [weak self] in + guard let self else { return } + + if isSignedIn { + await self.cachePreferencesIfNeeded() + } else { + self.clearPreferencesCache() + } + self.provider.publish(isSignedIn) + } + } + .store(in: &cancellables) + } + + func cachePreferencesIfNeeded() async { + if store.value(forKey: Key.preferences) as [TodoCategoryPreferenceResponse]? != nil { + return + } + + guard let preferences = try? await todoCategoryService.fetchCategoryPreferences() else { + return + } + + store.setValue(preferences, forKey: Key.preferences) + } + + func clearPreferencesCache() { + store.setValue(Optional<[TodoCategoryPreferenceResponse]>.none, forKey: Key.preferences) } } diff --git a/Application/DevLogData/Sources/Repository/PushNotificationRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/PushNotificationRepositoryImpl.swift index 293f452a..b3ffa34e 100644 --- a/Application/DevLogData/Sources/Repository/PushNotificationRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/PushNotificationRepositoryImpl.swift @@ -11,15 +11,22 @@ import DevLogCore import DevLogDomain final class PushNotificationRepositoryImpl: PushNotificationRepository { + private enum Key { + static let preferences = "TodoCategory.preferences" + } + private let pushNotificationService: PushNotificationService private let todoCategoryService: TodoCategoryService + private let store: MemoryCacheStore init( pushNotificationService: PushNotificationService, - todoCategoryService: TodoCategoryService + todoCategoryService: TodoCategoryService, + store: MemoryCacheStore ) { self.pushNotificationService = pushNotificationService self.todoCategoryService = todoCategoryService + self.store = store } /// 푸시 알림 On/Off 설정 @@ -59,7 +66,7 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { do { let cursorDTO = cursor.map { PushNotificationCursorDTO.fromDomain($0) } async let responseTask = pushNotificationService.requestNotifications(query, cursor: cursorDTO) - async let preferencesTask = todoCategoryService.fetchPreferences() + async let preferencesTask = todoCategoryPreferenceResponses() let (response, preferenceResponses) = try await (responseTask, preferencesTask) return try resolvePage(from: response, with: preferenceResponses.toDomain()) @@ -91,7 +98,8 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { Task { do { - let preferences = try await self.todoCategoryService.fetchPreferences().toDomain() + let preferences = try await self.todoCategoryPreferenceResponses() + .toDomain() let page = try self.resolvePage(from: response, with: preferences) subject.send(page) } catch { @@ -147,6 +155,16 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { } private extension PushNotificationRepositoryImpl { + func todoCategoryPreferenceResponses() async throws -> [TodoCategoryPreferenceResponse] { + if let preferences: [TodoCategoryPreferenceResponse] = store.value(forKey: Key.preferences) { + return preferences + } + + let preferences = try await todoCategoryService.fetchCategoryPreferences() + store.setValue(preferences, forKey: Key.preferences) + return preferences + } + func resolvePage( from response: PushNotificationPageResponse, with preferences: [TodoCategoryPreference] diff --git a/Application/DevLogData/Sources/Repository/TodoCategoryRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/TodoCategoryRepositoryImpl.swift index b74b2c9b..77977b36 100644 --- a/Application/DevLogData/Sources/Repository/TodoCategoryRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/TodoCategoryRepositoryImpl.swift @@ -8,25 +8,40 @@ import DevLogDomain final class TodoCategoryRepositoryImpl: TodoCategoryRepository { + private enum Key { + static let preferences = "TodoCategory.preferences" + } + private let todoCategoryService: TodoCategoryService + private let store: MemoryCacheStore - init(todoCategoryService: TodoCategoryService) { + init( + todoCategoryService: TodoCategoryService, + store: MemoryCacheStore + ) { self.todoCategoryService = todoCategoryService + self.store = store } - func fetchPreferences() async throws -> [TodoCategoryPreference] { + func fetchCategoryPreferences() async throws -> [TodoCategoryPreference] { do { - return try await todoCategoryService.fetchPreferences().toDomain() + if let preferences: [TodoCategoryPreferenceResponse] = store.value(forKey: Key.preferences) { + return preferences.toDomain() + } + + let responses = try await todoCategoryService.fetchCategoryPreferences() + store.setValue(responses, forKey: Key.preferences) + return responses.toDomain() } catch { throw error.toDomain() } } - func updatePreferences(_ preferences: [TodoCategoryPreference]) async throws { + func updateCategoryPreferences(_ preferences: [TodoCategoryPreference]) async throws { do { - try await todoCategoryService.updatePreferences( - preferences.map(TodoCategoryPreferenceResponse.fromDomain) - ) + let responses = preferences.map(TodoCategoryPreferenceResponse.fromDomain) + try await todoCategoryService.updateCategoryPreferences(responses) + store.setValue(responses, forKey: Key.preferences) } catch { throw error.toDomain() } diff --git a/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift index 3ea1f376..475e3091 100644 --- a/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift @@ -10,19 +10,26 @@ import DevLogCore import DevLogDomain final class TodoRepositoryImpl: TodoRepository { + private enum Key { + static let preferences = "TodoCategory.preferences" + } + private let todoService: TodoService private let todoCategoryService: TodoCategoryService + private let store: MemoryCacheStore private let widgetSyncEventBus: WidgetSyncEventBus private let todoMutationEventBus: TodoMutationEventBus init( todoService: TodoService, todoCategoryService: TodoCategoryService, + store: MemoryCacheStore, widgetSyncEventBus: WidgetSyncEventBus, todoMutationEventBus: TodoMutationEventBus ) { self.todoService = todoService self.todoCategoryService = todoCategoryService + self.store = store self.widgetSyncEventBus = widgetSyncEventBus self.todoMutationEventBus = todoMutationEventBus } @@ -31,11 +38,16 @@ final class TodoRepositoryImpl: TodoRepository { let responseCursor = cursor.map { TodoCursorDTO.fromDomain($0) } do { - async let response = todoService.fetchTodos(query, cursor: responseCursor) - async let preferences = todoCategoryService.fetchPreferences() - - let (todoResponse, todoPreferenceResponses) = try await (response, preferences) - let userTodoCategories: [UserTodoCategory] = todoPreferenceResponses.toDomain().compactMap { preference in + async let todos = todoService.fetchTodos(query, cursor: responseCursor) + async let preferences = todoCategoryPreferenceResponses() + + let (todoResponse, todoPreferenceResponses) = try await ( + todos, + preferences + ) + let userTodoCategories: [UserTodoCategory] = todoPreferenceResponses + .toDomain() + .compactMap { preference in guard case .user(let category) = preference.category else { return nil } @@ -59,10 +71,15 @@ final class TodoRepositoryImpl: TodoRepository { func fetchTodo(_ todoId: String) async throws -> Todo { do { async let response = todoService.fetchTodo(todoId: todoId) - async let preferences = todoCategoryService.fetchPreferences() - - let (todoResponse, todoPreferenceResponses) = try await (response, preferences) - let userTodoCategories: [UserTodoCategory] = todoPreferenceResponses.toDomain().compactMap { preference in + async let preferences = todoCategoryPreferenceResponses() + + let (todoResponse, todoPreferenceResponses) = try await ( + response, + preferences + ) + let userTodoCategories: [UserTodoCategory] = todoPreferenceResponses + .toDomain() + .compactMap { preference in guard case .user(let category) = preference.category else { return nil } @@ -79,10 +96,15 @@ final class TodoRepositoryImpl: TodoRepository { func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { do { async let responseTask = todoService.fetchReferences(numbers) - async let preferencesTask = todoCategoryService.fetchPreferences() - - let (responses, preferenceResponses) = try await (responseTask, preferencesTask) - let userTodoCategories: [UserTodoCategory] = preferenceResponses.toDomain().compactMap { preference in + async let preferencesTask = todoCategoryPreferenceResponses() + + let (responses, preferenceResponses) = try await ( + responseTask, + preferencesTask + ) + let userTodoCategories: [UserTodoCategory] = preferenceResponses + .toDomain() + .compactMap { preference in guard case .user(let category) = preference.category else { return nil } @@ -149,6 +171,16 @@ final class TodoRepositoryImpl: TodoRepository { } private extension TodoRepositoryImpl { + func todoCategoryPreferenceResponses() async throws -> [TodoCategoryPreferenceResponse] { + if let preferences: [TodoCategoryPreferenceResponse] = store.value(forKey: Key.preferences) { + return preferences + } + + let preferences = try await todoCategoryService.fetchCategoryPreferences() + store.setValue(preferences, forKey: Key.preferences) + return preferences + } + func resolve( _ response: TodoResponse, userTodoCategories: [UserTodoCategory] diff --git a/Application/DevLogData/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift index b628c600..8df7be08 100644 --- a/Application/DevLogData/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/WidgetTodoSnapshotRepositoryImpl.swift @@ -10,10 +10,10 @@ import DevLogCore import DevLogDomain final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository { - private let repository: TodoRepository + private let todoService: TodoService - init(repository: TodoRepository) { - self.repository = repository + init(todoService: TodoService) { + self.todoService = todoService } func fetchTodayTodos( @@ -22,19 +22,21 @@ final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository { sortOrder: TodoQuery.SortOrder, pageSize: Int ) async throws -> [WidgetTodoSnapshot] { - let todoPage = try await repository.fetchTodos( - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: dueDateFilter, - sortTarget: sortTarget, - sortOrder: sortOrder, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil + let query = TodoQuery( + completionFilter: .incomplete, + dueDateFilter: dueDateFilter, + sortTarget: sortTarget, + sortOrder: sortOrder, + pageSize: pageSize, + fetchAllPages: true ) - return todoPage.items.map(WidgetTodoSnapshot.fromDomain) + do { + let todoPage = try await todoService.fetchTodos(query, cursor: nil) + return todoPage.items.map(WidgetTodoSnapshot.fromResponse) + } catch { + throw error.toDomain() + } } func fetchHeatmapTodos( @@ -43,18 +45,35 @@ final class WidgetTodoSnapshotRepositoryImpl: WidgetTodoSnapshotRepository { nextQuarterStart: Date, pageSize: Int ) async throws -> [WidgetTodoSnapshot] { - let todoPage = try await repository.fetchTodos( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: sortTarget, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil + let query = TodoQuery( + sortDateFrom: quarterStart, + sortDateTo: nextQuarterStart, + includesDeleted: true, + sortTarget: sortTarget, + pageSize: pageSize, + fetchAllPages: true ) - return todoPage.items.map(WidgetTodoSnapshot.fromDomain) + do { + let todoPage = try await todoService.fetchTodos(query, cursor: nil) + return todoPage.items.map(WidgetTodoSnapshot.fromResponse) + } catch { + throw error.toDomain() + } + } +} + +private extension WidgetTodoSnapshot { + static func fromResponse(_ response: TodoResponse) -> Self { + WidgetTodoSnapshot( + id: response.id, + number: response.number, + title: response.title, + isPinned: response.isPinned, + createdAt: response.createdAt, + completedAt: response.completedAt, + deletedAt: response.deletedAt, + dueDate: response.dueDate + ) } } diff --git a/Application/DevLogData/Tests/Repository/AuthSessionRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/AuthSessionRepositoryImplTests.swift new file mode 100644 index 00000000..2d0a3bb9 --- /dev/null +++ b/Application/DevLogData/Tests/Repository/AuthSessionRepositoryImplTests.swift @@ -0,0 +1,177 @@ +// +// AuthSessionRepositoryImplTests.swift +// DevLogDataTests +// +// Created by opfic on 6/8/26. +// + +import Combine +import Foundation +import Testing +@testable import DevLogData + +struct AuthSessionRepositoryImplTests { + private enum Key { + static let preferences = "TodoCategory.preferences" + } + + @Test("로그인 세션 true는 category preference 캐싱 후 방출한다") + func 로그인_세션_true는_category_preference_캐싱_후_방출한다() async throws { + let preference = makePreferenceResponse() + let authService = AuthSessionAuthServiceSpy() + let todoCategoryService = AuthSessionTodoCategoryServiceSpy(preferences: [preference]) + let store = AuthSessionMemoryCacheStoreSpy() + let provider = AuthSessionStateProviderSpy() + let repository = AuthSessionRepositoryImpl( + authService: authService, + todoCategoryService: todoCategoryService, + store: store, + provider: provider + ) + let valueTask = Task { + for await value in repository.observeSignedIn().values where value { + return value + } + return false + } + + authService.send(true) + + #expect(await valueTask.value) + #expect(store.value(forKey: Key.preferences) == [preference]) + #expect(await todoCategoryService.fetchCategoryPreferencesCallCount() == 1) + #expect(provider.events.contains(true)) + } + + @Test("로그아웃 세션은 category preference 캐시를 제거한다") + func 로그아웃_세션은_category_preference_캐시를_제거한다() async throws { + let preference = makePreferenceResponse() + let authService = AuthSessionAuthServiceSpy(isSignedIn: true) + let todoCategoryService = AuthSessionTodoCategoryServiceSpy(preferences: [preference]) + let store = AuthSessionMemoryCacheStoreSpy() + let provider = AuthSessionStateProviderSpy() + store.setValue([preference], forKey: Key.preferences) + let repository = AuthSessionRepositoryImpl( + authService: authService, + todoCategoryService: todoCategoryService, + store: store, + provider: provider + ) + let valueTask = Task { + for await value in repository.observeSignedIn().values where value == false { + return value + } + return true + } + + authService.send(false) + + #expect(await valueTask.value == false) + #expect(store.value(forKey: Key.preferences) == Optional<[TodoCategoryPreferenceResponse]>.none) + #expect(provider.events.contains(false)) + } + + private func makePreferenceResponse() -> TodoCategoryPreferenceResponse { + TodoCategoryPreferenceResponse( + category: .user( + TodoCategoryPreferenceResponse.UserCategory( + id: "user-category-id", + name: "User Category", + colorHex: "#FFFFFF" + ) + ), + isVisible: true + ) + } +} + +private final class AuthSessionStateProviderSpy: AuthSessionStateProvider { + private let subject = PassthroughSubject() + private(set) var events = [Bool]() + + func publish(_ isSignedIn: Bool) { + events.append(isSignedIn) + subject.send(isSignedIn) + } + + func observeSignedIn() -> AnyPublisher { + subject.eraseToAnyPublisher() + } +} + +private final class AuthSessionAuthServiceSpy: AuthService { + private let subject: CurrentValueSubject + + var uid: String? { nil } + var providerIDs: [String] { [] } + var currentUserEmail: String? { nil } + var providerCount: Int { 0 } + + init(isSignedIn: Bool = false) { + self.subject = CurrentValueSubject(isSignedIn) + } + + func observeSignedIn() -> AnyPublisher { + subject.eraseToAnyPublisher() + } + + func beginSignIn() { } + func completeSignIn() { } + func cancelSignIn() { } + func getProviderID() async throws -> String? { nil } + func deleteCurrentUser() async throws { } + func clearCurrentSession() async throws { } + + func send(_ isSignedIn: Bool) { + subject.send(isSignedIn) + } +} + +private actor AuthSessionTodoCategoryServiceSpy: TodoCategoryService { + private let preferences: [TodoCategoryPreferenceResponse] + private let fetchDelay: Duration? + private var fetchCategoryPreferencesCount = 0 + + init( + preferences: [TodoCategoryPreferenceResponse], + fetchDelay: Duration? = nil + ) { + self.preferences = preferences + self.fetchDelay = fetchDelay + } + + func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] { + fetchCategoryPreferencesCount += 1 + if let fetchDelay { + try await Task.sleep(for: fetchDelay) + } + return preferences + } + + func updateCategoryPreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { } + + func fetchCategoryPreferencesCallCount() -> Int { + fetchCategoryPreferencesCount + } +} + +private final class AuthSessionMemoryCacheStoreSpy: MemoryCacheStore { + private var values = [String: Data]() + + func value(forKey key: String) -> T? { + guard let data = values[key] else { + return nil + } + + return try? JSONDecoder().decode(T.self, from: data) + } + + func setValue(_ value: T?, forKey key: String) { + guard let value else { + values.removeValue(forKey: key) + return + } + + values[key] = try? JSONEncoder().encode(value) + } +} diff --git a/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift index 8b9de1b3..64d45eb3 100644 --- a/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift +++ b/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift @@ -10,7 +10,7 @@ import Foundation import Testing import DevLogCore import DevLogDomain -@testable @preconcurrency import DevLogData +@testable import DevLogData struct TodoRepositoryImplTests { @Test("Todo 변경 성공 시 위젯 동기화와 mutation 이벤트를 발행한다") @@ -65,11 +65,13 @@ struct TodoRepositoryImplTests { private func makeFixture() -> Fixture { let todoService = TodoServiceSpy() let todoCategoryService = TodoCategoryServiceSpy() + let store = TodoRepositoryMemoryCacheStoreSpy() let widgetSyncEventBus = WidgetSyncEventBusSpy() let todoMutationEventBus = TodoMutationEventBusSpy() let repository = TodoRepositoryImpl( todoService: todoService, todoCategoryService: todoCategoryService, + store: store, widgetSyncEventBus: widgetSyncEventBus, todoMutationEventBus: todoMutationEventBus ) @@ -148,15 +150,32 @@ private actor TodoServiceSpy: TodoService { } private struct TodoCategoryServiceSpy: TodoCategoryService { - func fetchPreferences() async throws -> [TodoCategoryPreferenceResponse] { + func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] { [] } - func updatePreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { + func updateCategoryPreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { throw TodoRepositoryImplTestsError.unexpectedCall } } +private final class TodoRepositoryMemoryCacheStoreSpy: MemoryCacheStore { + private var values = [String: Any]() + + func value(forKey key: String) -> T? { + values[key] as? T + } + + func setValue(_ value: T?, forKey key: String) { + guard let value else { + values.removeValue(forKey: key) + return + } + + values[key] = value + } +} + private final class WidgetSyncEventBusSpy: WidgetSyncEventBus { private(set) var events = [WidgetSyncEvent]() diff --git a/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift index da1a6532..65a0ea8b 100644 --- a/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift +++ b/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift @@ -43,6 +43,14 @@ struct UserPreferencesRepositoryImplTests { } private final class UserDefaultsStoreSpy: UserDefaultsStore { + func value(forKey key: String) -> T? { + nil + } + + func setValue(_ value: T?, forKey key: String) { } + + func removeValues(withPrefix prefix: String) { } + func string(forKey key: String) -> String? { nil } diff --git a/Application/DevLogData/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift index 93f0922d..2efbb274 100644 --- a/Application/DevLogData/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift +++ b/Application/DevLogData/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift @@ -12,14 +12,14 @@ import DevLogDomain @testable import DevLogData struct WidgetTodoSnapshotRepositoryImplTests { - @Test("Today 위젯 Todo 조회는 기존 TodoRepository query와 snapshot 매핑을 사용한다") - func today_위젯_todo_조회는_기존_todorepository_query와_snapshot_매핑을_사용한다() async throws { - let repositorySpy = TodoRepositorySpy() - let repository = WidgetTodoSnapshotRepositoryImpl(repository: repositorySpy) + @Test("Today 위젯 Todo 조회는 TodoService query와 snapshot 매핑을 사용한다") + func today_위젯_todo_조회는_todoservice_query와_snapshot_매핑을_사용한다() async throws { + let todoServiceSpy = WidgetTodoSnapshotTodoServiceSpy() + let repository = WidgetTodoSnapshotRepositoryImpl(todoService: todoServiceSpy) let now = Date(timeIntervalSince1970: 100) - let todo = makeTodo(id: "today", createdAt: now, dueDate: now) + let todo = makeTodoResponse(id: "today", createdAt: now, dueDate: now) - await repositorySpy.setTodos([todo], for: .dueDate) + await todoServiceSpy.setTodos([todo], for: .dueDate) let snapshots = try await repository.fetchTodayTodos( dueDateFilter: .withDueDate, @@ -27,7 +27,7 @@ struct WidgetTodoSnapshotRepositoryImplTests { sortOrder: .oldest, pageSize: 100 ) - let queries = await repositorySpy.calledQueries() + let queries = await todoServiceSpy.calledQueries() #expect(snapshots == [makeSnapshot(id: "today", createdAt: now, dueDate: now)]) #expect(queries == [ @@ -42,15 +42,15 @@ struct WidgetTodoSnapshotRepositoryImplTests { ]) } - @Test("Heatmap 위젯 Todo 조회는 기존 TodoRepository query와 snapshot 매핑을 사용한다") - func heatmap_위젯_todo_조회는_기존_todorepository_query와_snapshot_매핑을_사용한다() async throws { - let repositorySpy = TodoRepositorySpy() - let repository = WidgetTodoSnapshotRepositoryImpl(repository: repositorySpy) + @Test("Heatmap 위젯 Todo 조회는 TodoService query와 snapshot 매핑을 사용한다") + func heatmap_위젯_todo_조회는_todoservice_query와_snapshot_매핑을_사용한다() async throws { + let todoServiceSpy = WidgetTodoSnapshotTodoServiceSpy() + let repository = WidgetTodoSnapshotRepositoryImpl(todoService: todoServiceSpy) let quarterStart = Date(timeIntervalSince1970: 100) let nextQuarterStart = Date(timeIntervalSince1970: 200) - let todo = makeTodo(id: "created", createdAt: quarterStart) + let todo = makeTodoResponse(id: "created", createdAt: quarterStart) - await repositorySpy.setTodos([todo], for: .createdAt) + await todoServiceSpy.setTodos([todo], for: .createdAt) let snapshots = try await repository.fetchHeatmapTodos( sortTarget: .createdAt, @@ -58,7 +58,7 @@ struct WidgetTodoSnapshotRepositoryImplTests { nextQuarterStart: nextQuarterStart, pageSize: 100 ) - let queries = await repositorySpy.calledQueries() + let queries = await todoServiceSpy.calledQueries() #expect(snapshots == [makeSnapshot(id: "created", createdAt: quarterStart)]) #expect(queries == [ @@ -73,90 +73,86 @@ struct WidgetTodoSnapshotRepositoryImplTests { ]) } - private func makeTodo( + private func makeSnapshot( id: String, createdAt: Date, completedAt: Date? = nil, deletedAt: Date? = nil, dueDate: Date? = nil - ) -> Todo { - Todo( + ) -> WidgetTodoSnapshot { + WidgetTodoSnapshot( id: id, - isPinned: false, - isCompleted: completedAt != nil, - isChecked: false, number: 1, title: id, - content: "", + isPinned: false, createdAt: createdAt, - updatedAt: createdAt, completedAt: completedAt, deletedAt: deletedAt, - dueDate: dueDate, - tags: [], - category: .system(.feature) + dueDate: dueDate ) } - private func makeSnapshot( + private func makeTodoResponse( id: String, createdAt: Date, completedAt: Date? = nil, deletedAt: Date? = nil, dueDate: Date? = nil - ) -> WidgetTodoSnapshot { - WidgetTodoSnapshot( + ) -> TodoResponse { + TodoResponse( id: id, + isPinned: false, + isCompleted: completedAt != nil, + isChecked: false, number: 1, title: id, - isPinned: false, + content: "", createdAt: createdAt, + updatedAt: createdAt, completedAt: completedAt, deletedAt: deletedAt, - dueDate: dueDate + dueDate: dueDate, + tags: [], + category: .raw(SystemTodoCategory.feature.rawValue) ) } } -private actor TodoRepositorySpy: TodoRepository { +private actor WidgetTodoSnapshotTodoServiceSpy: TodoService { private var queries = [TodoQuery]() - private var todosBySortTarget = [TodoQuery.SortTarget: [Todo]]() + private var todosBySortTarget = [TodoQuery.SortTarget: [TodoResponse]]() - func setTodos(_ todos: [Todo], for sortTarget: TodoQuery.SortTarget) { + func setTodos(_ todos: [TodoResponse], for sortTarget: TodoQuery.SortTarget) { todosBySortTarget[sortTarget] = todos } - func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + func fetchTodos(_ query: TodoQuery, cursor: TodoCursorDTO?) async throws -> TodoPageResponse { queries.append(query) - return TodoPage( + return TodoPageResponse( items: todosBySortTarget[query.sortTarget] ?? [], nextCursor: nil ) } - func fetchTodo(_ todoId: String) async throws -> Todo { - throw TodoRepositorySpyError.unexpectedCall - } - - func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReference] { - throw TodoRepositorySpyError.unexpectedCall + func upsertTodo(request: TodoRequest) async throws { + throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall } - func upsertTodo(_ todo: Todo) async throws { - throw TodoRepositorySpyError.unexpectedCall + func deleteTodo(todoId: String) async throws { + throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall } - func upsertTodo(_ todoDraft: TodoDraft) async throws { - throw TodoRepositorySpyError.unexpectedCall + func undoDeleteTodo(todoId: String) async throws { + throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall } - func deleteTodo(_ todoId: String) async throws { - throw TodoRepositorySpyError.unexpectedCall + func fetchTodo(todoId: String) async throws -> TodoResponse { + throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall } - func undoDeleteTodo(_ todoId: String) async throws { - throw TodoRepositorySpyError.unexpectedCall + func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] { + throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall } func calledQueries() -> [TodoQuery] { @@ -164,6 +160,6 @@ private actor TodoRepositorySpy: TodoRepository { } } -private enum TodoRepositorySpyError: Error { +private enum WidgetTodoSnapshotTodoServiceSpyError: Error { case unexpectedCall } diff --git a/Application/DevLogDomain/Sources/Protocol/TodoCategoryRepository.swift b/Application/DevLogDomain/Sources/Protocol/TodoCategoryRepository.swift index 686932ca..8b2be92b 100644 --- a/Application/DevLogDomain/Sources/Protocol/TodoCategoryRepository.swift +++ b/Application/DevLogDomain/Sources/Protocol/TodoCategoryRepository.swift @@ -6,6 +6,6 @@ // public protocol TodoCategoryRepository { - func fetchPreferences() async throws -> [TodoCategoryPreference] - func updatePreferences(_ preferences: [TodoCategoryPreference]) async throws + func fetchCategoryPreferences() async throws -> [TodoCategoryPreference] + func updateCategoryPreferences(_ preferences: [TodoCategoryPreference]) async throws } diff --git a/Application/DevLogDomain/Sources/UseCase/Auth/Session/ObserveAuthSessionUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/Auth/Session/ObserveAuthSessionUseCaseImpl.swift index 6dc3054f..68b19f98 100644 --- a/Application/DevLogDomain/Sources/UseCase/Auth/Session/ObserveAuthSessionUseCaseImpl.swift +++ b/Application/DevLogDomain/Sources/UseCase/Auth/Session/ObserveAuthSessionUseCaseImpl.swift @@ -10,11 +10,11 @@ import Combine public final class ObserveAuthSessionUseCaseImpl: ObserveAuthSessionUseCase { private let repository: AuthSessionRepository - public func observe() -> AnyPublisher { - repository.observeSignedIn() - } - init(_ repository: AuthSessionRepository) { self.repository = repository } + + public func observe() -> AnyPublisher { + repository.observeSignedIn() + } } diff --git a/Application/DevLogDomain/Sources/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift index f70faf26..7338fd51 100644 --- a/Application/DevLogDomain/Sources/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift +++ b/Application/DevLogDomain/Sources/UseCase/TodoCategory/Fetch/FetchTodoCategoryPreferencesUseCaseImpl.swift @@ -13,6 +13,6 @@ public final class FetchTodoCategoryPreferencesUseCaseImpl: FetchTodoCategoryPre } public func execute() async throws -> [TodoCategoryPreference] { - try await todoCategoryRepository.fetchPreferences() + try await todoCategoryRepository.fetchCategoryPreferences() } } diff --git a/Application/DevLogDomain/Sources/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift index a280c0fd..fe78098a 100644 --- a/Application/DevLogDomain/Sources/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift +++ b/Application/DevLogDomain/Sources/UseCase/TodoCategory/Update/UpdateTodoCategoryPreferencesUseCaseImpl.swift @@ -13,6 +13,6 @@ public final class UpdateTodoCategoryPreferencesUseCaseImpl: UpdateTodoCategoryP } public func execute(_ preferences: [TodoCategoryPreference]) async throws { - try await todoCategoryRepository.updatePreferences(preferences) + try await todoCategoryRepository.updateCategoryPreferences(preferences) } } diff --git a/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift index 7c4959cf..e058eb2b 100644 --- a/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift @@ -29,7 +29,7 @@ final class TodoCategoryServiceImpl: TodoCategoryService { private let store = Firestore.firestore() private let logger = Logger(category: "TodoCategoryServiceImpl") - func fetchPreferences() async throws -> [TodoCategoryPreferenceResponse] { + func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] { guard let uid = Auth.auth().currentUser?.uid else { logger.error("User not authenticated") throw DataLayerError.notAuthenticated @@ -61,7 +61,7 @@ final class TodoCategoryServiceImpl: TodoCategoryService { } } - func updatePreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { + func updateCategoryPreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { guard let uid = Auth.auth().currentUser?.uid else { logger.error("User not authenticated") throw DataLayerError.notAuthenticated diff --git a/Application/DevLogPersistence/Sources/Cache/MemoryCacheStoreImpl.swift b/Application/DevLogPersistence/Sources/Cache/MemoryCacheStoreImpl.swift new file mode 100644 index 00000000..83a3342e --- /dev/null +++ b/Application/DevLogPersistence/Sources/Cache/MemoryCacheStoreImpl.swift @@ -0,0 +1,33 @@ +// +// MemoryCacheStoreImpl.swift +// DevLogPersistence +// +// Created by opfic on 6/9/26. +// + +import Foundation +import DevLogData + +final class MemoryCacheStoreImpl: MemoryCacheStore { + private let lock = NSLock() + private var values = [String: Any]() + + func value(forKey key: String) -> T? { + lock.lock() + defer { lock.unlock() } + + return values[key] as? T + } + + func setValue(_ value: T?, forKey key: String) { + lock.lock() + defer { lock.unlock() } + + guard let value else { + values.removeValue(forKey: key) + return + } + + values[key] = value + } +} diff --git a/Application/DevLogPersistence/Sources/Persistence/UserDefaultsStoreImpl.swift b/Application/DevLogPersistence/Sources/Persistence/UserDefaultsStoreImpl.swift index 7775a701..21cd825c 100644 --- a/Application/DevLogPersistence/Sources/Persistence/UserDefaultsStoreImpl.swift +++ b/Application/DevLogPersistence/Sources/Persistence/UserDefaultsStoreImpl.swift @@ -19,6 +19,33 @@ final class UserDefaultsStoreImpl: UserDefaultsStore { userDefaults.string(forKey: key) } + func value(forKey key: String) -> T? { + let decoder = JSONDecoder() + guard let data = userDefaults.data(forKey: key) else { return nil } + guard let value = try? decoder.decode(T.self, from: data) else { + userDefaults.removeObject(forKey: key) + return nil + } + return value + } + + func setValue(_ value: T?, forKey key: String) { + let encoder = JSONEncoder() + guard let value else { + userDefaults.removeObject(forKey: key) + return + } + + guard let data = try? encoder.encode(value) else { return } + userDefaults.set(data, forKey: key) + } + + func removeValues(withPrefix prefix: String) { + userDefaults.dictionaryRepresentation().keys + .filter { $0.hasPrefix(prefix) } + .forEach(userDefaults.removeObject(forKey:)) + } + func setString(_ value: String?, forKey key: String) { userDefaults.set(value, forKey: key) } diff --git a/Application/DevLogPersistence/Sources/PersistenceAssembler.swift b/Application/DevLogPersistence/Sources/PersistenceAssembler.swift index 2e472d83..ef7fdb05 100644 --- a/Application/DevLogPersistence/Sources/PersistenceAssembler.swift +++ b/Application/DevLogPersistence/Sources/PersistenceAssembler.swift @@ -17,6 +17,10 @@ public final class PersistenceAssembler: Assembler { UserDefaultsStoreImpl() } + container.register(MemoryCacheStore.self) { + MemoryCacheStoreImpl() + } + container.register(ThemeStore.self) { ThemeStoreImpl() } diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index d6f69006..4ff8d5c5 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -6,7 +6,7 @@ // import ComposableArchitecture -@preconcurrency import DevLogDomain +import DevLogDomain import Foundation @Reducer diff --git a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift b/Application/DevLogPresentation/Sources/Root/RootViewModel.swift index 28bacbff..405f395e 100644 --- a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift +++ b/Application/DevLogPresentation/Sources/Root/RootViewModel.swift @@ -32,11 +32,17 @@ final class RootViewModel: StorePattern { enum SideEffect { case clearApplicationBadgeCount + case observeNetworkConnectivity + case observeSession + case observeTheme case trackLoginScreen } private(set) var state: State private var cancellables = Set() + private var isObservingNetworkConnectivity = false + private var isObservingSession = false + private var isObservingTheme = false private let sessionUseCase: ObserveAuthSessionUseCase private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase private let systemThemeUseCase: ObserveSystemThemeUseCase @@ -53,10 +59,6 @@ final class RootViewModel: StorePattern { self.systemThemeUseCase = systemThemeUseCase self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase self.state = State() - - setupNetworkObserving() - setupSessionObserving() - setupThemeObserving() } func reduce(with action: Action) -> [SideEffect] { @@ -65,7 +67,12 @@ final class RootViewModel: StorePattern { switch action { case .onAppear: - effects = [.clearApplicationBadgeCount] + effects = [ + .clearApplicationBadgeCount, + .observeNetworkConnectivity, + .observeSession, + .observeTheme + ] case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .networkStatusChanged(let isConnected): @@ -91,6 +98,12 @@ final class RootViewModel: StorePattern { switch effect { case .clearApplicationBadgeCount: UNUserNotificationCenter.current().setBadgeCount(0) { _ in } + case .observeNetworkConnectivity: + setupNetworkObserving() + case .observeSession: + setupSessionObserving() + case .observeTheme: + setupThemeObserving() case .trackLoginScreen: trackAnalyticsEventUseCase.execute(.screenView("login")) } @@ -109,6 +122,9 @@ private extension RootViewModel { } func setupNetworkObserving() { + guard !isObservingNetworkConnectivity else { return } + isObservingNetworkConnectivity = true + networkConnectivityUseCase.observe() .receive(on: DispatchQueue.main) .sink { [weak self] isConnected in @@ -118,6 +134,9 @@ private extension RootViewModel { } func setupSessionObserving() { + guard !isObservingSession else { return } + isObservingSession = true + sessionUseCase.observe() .removeDuplicates() .receive(on: DispatchQueue.main) @@ -128,6 +147,9 @@ private extension RootViewModel { } func setupThemeObserving() { + guard !isObservingTheme else { return } + isObservingTheme = true + systemThemeUseCase.observe() .removeDuplicates() .receive(on: DispatchQueue.main) diff --git a/Application/DevLogWidget/Sources/Handler/WidgetSessionSyncHandler.swift b/Application/DevLogWidget/Sources/Handler/WidgetSessionSyncHandler.swift index 824e971b..bfc8beea 100644 --- a/Application/DevLogWidget/Sources/Handler/WidgetSessionSyncHandler.swift +++ b/Application/DevLogWidget/Sources/Handler/WidgetSessionSyncHandler.swift @@ -10,19 +10,19 @@ import Foundation import DevLogData public final class WidgetSessionSyncHandler { - private let authService: AuthService + private let provider: AuthSessionStateProvider private let widgetSyncEventBus: WidgetSyncEventBus private var hasRequestedWidgetSync = false private var cancellables = Set() public init( - authService: AuthService, + provider: AuthSessionStateProvider, widgetSyncEventBus: WidgetSyncEventBus ) { - self.authService = authService + self.provider = provider self.widgetSyncEventBus = widgetSyncEventBus - authService.observeSignedIn() + provider.observeSignedIn() .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] isSignedIn in diff --git a/Application/DevLogWidget/Sources/Provider/AuthSessionStateProviderImpl.swift b/Application/DevLogWidget/Sources/Provider/AuthSessionStateProviderImpl.swift new file mode 100644 index 00000000..b0307bbe --- /dev/null +++ b/Application/DevLogWidget/Sources/Provider/AuthSessionStateProviderImpl.swift @@ -0,0 +1,25 @@ +// +// AuthSessionStateProviderImpl.swift +// DevLogWidget +// +// Created by opfic on 6/9/26. +// + +import Combine +import DevLogData + +public final class AuthSessionStateProviderImpl: AuthSessionStateProvider { + private let subject = CurrentValueSubject(nil) + + public init() { } + + public func publish(_ isSignedIn: Bool) { + subject.send(isSignedIn) + } + + public func observeSignedIn() -> AnyPublisher { + subject + .compactMap { $0 } + .eraseToAnyPublisher() + } +} diff --git a/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift b/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift index 6c09331d..61244806 100644 --- a/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift +++ b/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift @@ -12,6 +12,10 @@ public final class WidgetAssembler: Assembler { public init() { } public func assemble(_ container: any DIContainer) { + container.register(AuthSessionStateProvider.self) { + AuthSessionStateProviderImpl() + } + container.register(WidgetSyncEventBus.self) { WidgetSyncEventBusImpl() } @@ -24,7 +28,7 @@ public final class WidgetAssembler: Assembler { } container.register(WidgetSessionSyncHandler.self) { WidgetSessionSyncHandler( - authService: container.resolve(AuthService.self), + provider: container.resolve(AuthSessionStateProvider.self), widgetSyncEventBus: container.resolve(WidgetSyncEventBus.self) ) } diff --git a/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift b/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift index af3b4f9a..45c647dc 100644 --- a/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift +++ b/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift @@ -14,15 +14,15 @@ import DevLogData struct WidgetSessionSyncHandlerTests { @Test("로그인 세션 true 첫 진입에서만 위젯 초기 동기화를 요청한다") func 로그인_세션_true_첫_진입에서만_위젯_초기_동기화를_요청한다() async { - let authServiceSpy = AuthServiceSpy() + let provider = AuthSessionStateProviderSpy() let widgetSyncEventBusSpy = WidgetSyncEventBusSpy() let widgetSessionSyncHandler = WidgetSessionSyncHandler( - authService: authServiceSpy, + provider: provider, widgetSyncEventBus: widgetSyncEventBusSpy ) - authServiceSpy.send(true) - authServiceSpy.send(true) + provider.publish(true) + provider.publish(true) await withCheckedContinuation { continuation in DispatchQueue.main.async { @@ -35,16 +35,16 @@ struct WidgetSessionSyncHandlerTests { @Test("로그아웃 이후 재로그인 시 위젯 초기 동기화를 다시 요청한다") func 로그아웃_이후_재로그인_시_위젯_초기_동기화를_다시_요청한다() async { - let authServiceSpy = AuthServiceSpy() + let provider = AuthSessionStateProviderSpy() let widgetSyncEventBusSpy = WidgetSyncEventBusSpy() let widgetSessionSyncHandler = WidgetSessionSyncHandler( - authService: authServiceSpy, + provider: provider, widgetSyncEventBus: widgetSyncEventBusSpy ) - authServiceSpy.send(true) - authServiceSpy.send(false) - authServiceSpy.send(true) + provider.publish(true) + provider.publish(false) + provider.publish(true) await withCheckedContinuation { continuation in DispatchQueue.main.async { @@ -56,27 +56,15 @@ struct WidgetSessionSyncHandlerTests { } } -private final class AuthServiceSpy: AuthService { - private let currentValueSubject = CurrentValueSubject(false) - - var uid: String? { nil } - var providerIDs: [String] { [] } - var currentUserEmail: String? { nil } - var providerCount: Int { 0 } +private final class AuthSessionStateProviderSpy: AuthSessionStateProvider { + private let subject = PassthroughSubject() func observeSignedIn() -> AnyPublisher { - currentValueSubject.eraseToAnyPublisher() + subject.eraseToAnyPublisher() } - func beginSignIn() { } - func completeSignIn() { } - func cancelSignIn() { } - func getProviderID() async throws -> String? { nil } - func deleteCurrentUser() async throws { } - func clearCurrentSession() async throws { } - - func send(_ isSignedIn: Bool) { - currentValueSubject.send(isSignedIn) + func publish(_ isSignedIn: Bool) { + subject.send(isSignedIn) } }