From 07d291acf2b1f0b5856605f3c5fa40b450cd4933 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 8 Jul 2026 23:18:10 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20WebPage=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=97=90=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20scope=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Protocol/WebPageImageStore.swift | 10 +++++----- .../Data/Sources/Protocol/WebPageMetadataService.swift | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Application/Data/Sources/Protocol/WebPageImageStore.swift b/Application/Data/Sources/Protocol/WebPageImageStore.swift index 22f58f7d..682c1c1a 100644 --- a/Application/Data/Sources/Protocol/WebPageImageStore.swift +++ b/Application/Data/Sources/Protocol/WebPageImageStore.swift @@ -8,9 +8,9 @@ import Foundation public protocol WebPageImageStore { - func cachedImageURL(for url: URL) async throws -> URL - func saveImage(_ data: Data, for url: URL) async throws -> URL - func dirSizeInBytes() async -> Int64 - func clearDirectory() async throws - func removeImage(for url: URL) async throws -> Bool + func cachedImageURL(for url: URL, accountID: String?) async throws -> URL + func saveImage(_ data: Data, for url: URL, accountID: String?) async throws -> URL + func dirSizeInBytes(accountID: String?) async -> Int64 + func clearDirectory(accountID: String?) async throws + func removeImage(for url: URL, accountID: String?) async throws -> Bool } diff --git a/Application/Data/Sources/Protocol/WebPageMetadataService.swift b/Application/Data/Sources/Protocol/WebPageMetadataService.swift index 6a305b1b..7a20ab36 100644 --- a/Application/Data/Sources/Protocol/WebPageMetadataService.swift +++ b/Application/Data/Sources/Protocol/WebPageMetadataService.swift @@ -8,7 +8,7 @@ import Foundation public protocol WebPageMetadataService { - func fetchMetadata(from urlString: String) async throws -> WebPageMetadataResponse - func removeCachedImage(for urlString: String) async - func cachedImageURL(for urlString: String) async throws -> URL + func fetchMetadata(from urlString: String, accountID: String?) async throws -> WebPageMetadataResponse + func removeCachedImage(for urlString: String, accountID: String?) async + func cachedImageURL(for urlString: String, accountID: String?) async throws -> URL } From ef69d424c7c38f514e197065ad9770be24414984 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 8 Jul 2026 23:18:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20WebPage=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20scope=20=EC=A0=84=EB=8B=AC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Data/Sources/DataAssembler.swift | 6 ++-- .../WebPageImageRepositoryImpl.swift | 11 +++++-- .../Repository/WebPageRepositoryImpl.swift | 30 +++++++++++-------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/Application/Data/Sources/DataAssembler.swift b/Application/Data/Sources/DataAssembler.swift index 1b938b82..98f4f5b2 100644 --- a/Application/Data/Sources/DataAssembler.swift +++ b/Application/Data/Sources/DataAssembler.swift @@ -117,13 +117,15 @@ public final class DataAssembler: Assembler { container.register(WebPageRepository.self) { WebPageRepositoryImpl( - webPageService: container.resolve(WebPageService.self), - metadataService: container.resolve(WebPageMetadataService.self) + authService: container.resolve(AuthService.self), + metadataService: container.resolve(WebPageMetadataService.self), + webPageService: container.resolve(WebPageService.self) ) } container.register(WebPageImageRepository.self) { WebPageImageRepositoryImpl( + authService: container.resolve(AuthService.self), store: container.resolve(WebPageImageStore.self) ) } diff --git a/Application/Data/Sources/Repository/WebPageImageRepositoryImpl.swift b/Application/Data/Sources/Repository/WebPageImageRepositoryImpl.swift index 0e4bbe07..9517e7ad 100644 --- a/Application/Data/Sources/Repository/WebPageImageRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/WebPageImageRepositoryImpl.swift @@ -8,17 +8,22 @@ import Domain final class WebPageImageRepositoryImpl: WebPageImageRepository { + private let authService: AuthService private let store: WebPageImageStore - init(store: WebPageImageStore) { + init( + authService: AuthService, + store: WebPageImageStore + ) { + self.authService = authService self.store = store } func fetchDirSizeInBytes() async -> Int64 { - await store.dirSizeInBytes() + await store.dirSizeInBytes(accountID: authService.uid) } func clearDirectory() async throws { - try await store.clearDirectory() + try await store.clearDirectory(accountID: authService.uid) } } diff --git a/Application/Data/Sources/Repository/WebPageRepositoryImpl.swift b/Application/Data/Sources/Repository/WebPageRepositoryImpl.swift index 020785c8..5926f2b7 100644 --- a/Application/Data/Sources/Repository/WebPageRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/WebPageRepositoryImpl.swift @@ -10,26 +10,30 @@ import UIKit import Domain final class WebPageRepositoryImpl: WebPageRepository { - private let webPageService: WebPageService + private let authService: AuthService private let metadataService: WebPageMetadataService + private let webPageService: WebPageService init( - webPageService: WebPageService, - metadataService: WebPageMetadataService + authService: AuthService, + metadataService: WebPageMetadataService, + webPageService: WebPageService ) { - self.webPageService = webPageService + self.authService = authService self.metadataService = metadataService + self.webPageService = webPageService } func fetch(_ query: String) async throws -> [WebPage] { do { + let accountID = authService.uid let responses = try await webPageService.fetchWebPages(query) var pages: [WebPage] = [] pages.reserveCapacity(responses.count) for response in responses { - if await needsImageRestore(response) { - if let restored = try? await restoreWebPage(response) { + if await needsImageRestore(response, accountID: accountID) { + if let restored = try? await restoreWebPage(response, accountID: accountID) { pages.append(restored) } else if let page = try? responseWithoutImage(response).toDomain() { pages.append(page) @@ -49,7 +53,8 @@ final class WebPageRepositoryImpl: WebPageRepository { func upsert(_ urlString: String) async throws { do { - let metadata = try await metadataService.fetchMetadata(from: urlString) + let accountID = authService.uid + let metadata = try await metadataService.fetchMetadata(from: urlString, accountID: accountID) let request = WebPageRequest( title: metadata.title, url: urlString, @@ -65,8 +70,9 @@ final class WebPageRepositoryImpl: WebPageRepository { func delete(id: String, urlString: String) async throws { do { + let accountID = authService.uid try await webPageService.deleteWebPage(id) - await metadataService.removeCachedImage(for: urlString) + await metadataService.removeCachedImage(for: urlString, accountID: accountID) } catch { throw error.toDomain() } @@ -82,7 +88,7 @@ final class WebPageRepositoryImpl: WebPageRepository { } private extension WebPageRepositoryImpl { - func needsImageRestore(_ response: WebPageResponse) async -> Bool { + func needsImageRestore(_ response: WebPageResponse, accountID: String?) async -> Bool { guard !response.imageURL.isEmpty, let imageURL = URL(string: response.imageURL), imageURL.isFileURL else { @@ -91,7 +97,7 @@ private extension WebPageRepositoryImpl { let expectedImageURL: URL do { - expectedImageURL = try await metadataService.cachedImageURL(for: response.url) + expectedImageURL = try await metadataService.cachedImageURL(for: response.url, accountID: accountID) } catch { return true } @@ -113,8 +119,8 @@ private extension WebPageRepositoryImpl { }.value } - func restoreWebPage(_ response: WebPageResponse) async throws -> WebPage? { - let metadata = try await metadataService.fetchMetadata(from: response.url) + func restoreWebPage(_ response: WebPageResponse, accountID: String?) async throws -> WebPage? { + let metadata = try await metadataService.fetchMetadata(from: response.url, accountID: accountID) let request = WebPageRequest( title: metadata.title, url: response.url, From c2e81575647b264de65f05a41f752df373062241 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 8 Jul 2026 23:18:34 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20WebPage=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=BA=90=EC=8B=9C=20=EA=B3=84=EC=A0=95=EB=B3=84=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/WebPageMetadataServiceImpl.swift | 20 +++-- .../Persistence/WebPageImageStoreImpl.swift | 85 ++++++++++++++----- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/Application/Infra/Sources/Service/WebPageMetadataServiceImpl.swift b/Application/Infra/Sources/Service/WebPageMetadataServiceImpl.swift index 86d8d797..36e68ba1 100644 --- a/Application/Infra/Sources/Service/WebPageMetadataServiceImpl.swift +++ b/Application/Infra/Sources/Service/WebPageMetadataServiceImpl.swift @@ -29,7 +29,7 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { self.imageStore = store } - func fetchMetadata(from urlString: String) async throws -> WebPageMetadataResponse { + func fetchMetadata(from urlString: String, accountID: String?) async throws -> WebPageMetadataResponse { logger.info("Fetching metadata for URL: \(urlString)") guard let url = URL(string: urlString) else { @@ -42,7 +42,7 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { provider.timeout = 10.0 let metadata = try await provider.startFetchingMetadata(for: url) - let imageURL = try await extractImageURL(from: metadata.imageProvider, url: url) + let imageURL = try await extractImageURL(from: metadata.imageProvider, url: url, accountID: accountID) logger.info("Successfully fetched metadata for: \(metadata.title ?? "Unknown")") return WebPageMetadataResponse( @@ -57,14 +57,14 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { } } - func removeCachedImage(for urlString: String) async { + func removeCachedImage(for urlString: String, accountID: String?) async { guard let url = URL(string: urlString) else { logger.error("Invalid URL for cached image removal: \(urlString)") return } do { - let removed = try await imageStore.removeImage(for: url) + let removed = try await imageStore.removeImage(for: url, accountID: accountID) if removed { logger.info("Removed cached image for URL: \(urlString)") @@ -75,13 +75,13 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { } } - func cachedImageURL(for urlString: String) async throws -> URL { + func cachedImageURL(for urlString: String, accountID: String?) async throws -> URL { guard let url = URL(string: urlString) else { throw URLError(.badURL) } do { - return try await imageStore.cachedImageURL(for: url) + return try await imageStore.cachedImageURL(for: url, accountID: accountID) } catch { logger.error("Failed to fetch cached image URL", error: error) record(error, code: .cachedImageURL) @@ -89,11 +89,15 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { } } - private func extractImageURL(from imageProvider: NSItemProvider?, url: URL) async throws -> URL? { + private func extractImageURL( + from imageProvider: NSItemProvider?, + url: URL, + accountID: String? + ) async throws -> URL? { guard let imageProvider else { return nil } guard let data = try await imageData(from: imageProvider) else { return nil } - return try await imageStore.saveImage(data, for: url) + return try await imageStore.saveImage(data, for: url, accountID: accountID) } private func imageData(from imageProvider: NSItemProvider) async throws -> Data? { diff --git a/Application/Persistence/Sources/Persistence/WebPageImageStoreImpl.swift b/Application/Persistence/Sources/Persistence/WebPageImageStoreImpl.swift index b5f28444..8abd05bd 100644 --- a/Application/Persistence/Sources/Persistence/WebPageImageStoreImpl.swift +++ b/Application/Persistence/Sources/Persistence/WebPageImageStoreImpl.swift @@ -15,37 +15,37 @@ final class WebPageImageStoreImpl: WebPageImageStore { qos: .utility ) - func cachedImageURL(for url: URL) async throws -> URL { + func cachedImageURL(for url: URL, accountID: String? = nil) async throws -> URL { return try await perform { - try Self.cachedImageURL(for: url) + try Self.cachedImageURL(for: url, accountID: accountID) } } - func saveImage(_ data: Data, for url: URL) async throws -> URL { + func saveImage(_ data: Data, for url: URL, accountID: String? = nil) async throws -> URL { return try await perform { - try Self.saveImage(data, for: url) + try Self.saveImage(data, for: url, accountID: accountID) } } - func dirSizeInBytes() async -> Int64 { + func dirSizeInBytes(accountID: String? = nil) async -> Int64 { do { return try await perform { - try Self.dirSizeInBytes() + try Self.dirSizeInBytes(accountID: accountID) } } catch { return 0 } } - func clearDirectory() async throws { + func clearDirectory(accountID: String? = nil) async throws { try await perform { - try Self.clearDirectory() + try Self.clearDirectory(accountID: accountID) } } - func removeImage(for url: URL) async throws -> Bool { + func removeImage(for url: URL, accountID: String? = nil) async throws -> Bool { return try await perform { - try Self.removeImage(for: url) + try Self.removeImage(for: url, accountID: accountID) } } } @@ -68,9 +68,13 @@ private extension WebPageImageStoreImpl { return hashValue.map { String(format: "%02x", $0) }.joined() } - static func cachedImageURL(for url: URL) throws -> URL { + static func cachedImageURL(for url: URL, accountID: String?) throws -> URL { let fileManager = FileManager.default - let imageDirectoryURL = try imageDirectoryURL(create: true, fileManager: fileManager) + let imageDirectoryURL = try imageDirectoryURL( + accountID: accountID, + create: true, + fileManager: fileManager + ) let fileName = hashedFileName(for: url) return imageDirectoryURL @@ -78,15 +82,19 @@ private extension WebPageImageStoreImpl { .appendingPathExtension("jpeg") } - static func saveImage(_ data: Data, for url: URL) throws -> URL { - let fileURL = try cachedImageURL(for: url) + static func saveImage(_ data: Data, for url: URL, accountID: String?) throws -> URL { + let fileURL = try cachedImageURL(for: url, accountID: accountID) try data.write(to: fileURL, options: [.atomic]) return fileURL } - static func dirSizeInBytes() throws -> Int64 { + static func dirSizeInBytes(accountID: String?) throws -> Int64 { let fileManager = FileManager.default - let imageDirectoryURL = try imageDirectoryURL(create: false, fileManager: fileManager) + let imageDirectoryURL = try imageDirectoryURL( + accountID: accountID, + create: false, + fileManager: fileManager + ) guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return 0 } guard let enumerator = fileManager.enumerator( at: imageDirectoryURL, @@ -108,9 +116,13 @@ private extension WebPageImageStoreImpl { return total } - static func clearDirectory() throws { + static func clearDirectory(accountID: String?) throws { let fileManager = FileManager.default - let imageDirectoryURL = try imageDirectoryURL(create: false, fileManager: fileManager) + let imageDirectoryURL = try imageDirectoryURL( + accountID: accountID, + create: false, + fileManager: fileManager + ) guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return } let contentURLs = try fileManager.contentsOfDirectory( at: imageDirectoryURL, @@ -122,22 +134,27 @@ private extension WebPageImageStoreImpl { } } - static func removeImage(for url: URL) throws -> Bool { + static func removeImage(for url: URL, accountID: String?) throws -> Bool { let fileManager = FileManager.default - let fileURL = try cachedImageURL(for: url) + let fileURL = try cachedImageURL(for: url, accountID: accountID) guard fileManager.fileExists(atPath: fileURL.path) else { return false } try fileManager.removeItem(at: fileURL) return true } - static func imageDirectoryURL(create: Bool, fileManager: FileManager) throws -> URL { + static func imageDirectoryURL(accountID: String?, create: Bool, fileManager: FileManager) throws -> URL { let directory = try fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: create ) - let imageDirectory = directory.appendingPathComponent("webPageImages", isDirectory: true) + let imageBaseDirectory = directory.appendingPathComponent("webPageImages", isDirectory: true) + let imageDirectory = accountImageDirectoryURL( + in: imageBaseDirectory, + accountID: accountID + ) + if create && !fileManager.fileExists(atPath: imageDirectory.path) { try fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true) } @@ -150,4 +167,28 @@ private extension WebPageImageStoreImpl { return imageDirectory } + + static func accountImageDirectoryURL(in imageBaseDirectory: URL, accountID: String?) -> URL { + guard let accountID = normalizedAccountID(accountID) else { + return imageBaseDirectory + } + + return imageBaseDirectory.appendingPathComponent( + hashedDirectoryName(for: accountID), + isDirectory: true + ) + } + + static func normalizedAccountID(_ accountID: String?) -> String? { + guard let accountID = accountID?.trimmingCharacters(in: .whitespacesAndNewlines), + !accountID.isEmpty else { + return nil + } + return accountID + } + + static func hashedDirectoryName(for accountID: String) -> String { + let hashValue = SHA256.hash(data: Data(accountID.utf8)) + return hashValue.map { String(format: "%02x", $0) }.joined() + } } From 1e80bedabe82a0506eae4f32f8f6ece324fec781 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 9 Jul 2026 00:18:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=ED=94=BC=EC=B3=90=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuthSessionRepositoryImplTests.swift | 177 ------------- .../ProfileImageDataRepositoryImplTests.swift | 89 ------- .../TodoMutationEventBusImplTests.swift | 28 -- .../Repository/TodoRepositoryImplTests.swift | 242 ------------------ .../UserPreferencesRepositoryImplTests.swift | 118 --------- .../WebPageRepositoryImplTests.swift | 226 ++++++++++++++++ ...idgetTodoSnapshotRepositoryImplTests.swift | 165 ------------ .../Tests/Widget/WidgetSyncEventTests.swift | 17 -- Application/Domain/Project.swift | 4 +- .../Service/FunctionAPIEndpointTests.swift | 27 -- .../WebPageImageStoreImplTests.swift | 129 ++-------- .../PushNotificationListTestSpies.swift | 1 + .../WidgetSnapshotPreferenceStoreTests.swift | 59 ----- .../Widget/WidgetSnapshotUpdaterTests.swift | 45 ---- .../Widget/WidgetSyncEventBusTests.swift | 28 -- .../Common/WidgetSharedConstantsTests.swift | 19 -- 16 files changed, 257 insertions(+), 1117 deletions(-) delete mode 100644 Application/Data/Tests/Repository/AuthSessionRepositoryImplTests.swift delete mode 100644 Application/Data/Tests/Repository/ProfileImageDataRepositoryImplTests.swift delete mode 100644 Application/Data/Tests/Repository/TodoMutationEventBusImplTests.swift delete mode 100644 Application/Data/Tests/Repository/TodoRepositoryImplTests.swift delete mode 100644 Application/Data/Tests/Repository/UserPreferencesRepositoryImplTests.swift create mode 100644 Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift delete mode 100644 Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift delete mode 100644 Application/Data/Tests/Widget/WidgetSyncEventTests.swift delete mode 100644 Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift delete mode 100644 Application/Widget/Tests/Widget/WidgetSnapshotPreferenceStoreTests.swift delete mode 100644 Application/Widget/Tests/Widget/WidgetSyncEventBusTests.swift delete mode 100644 Widget/WidgetCore/Tests/Common/WidgetSharedConstantsTests.swift diff --git a/Application/Data/Tests/Repository/AuthSessionRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthSessionRepositoryImplTests.swift deleted file mode 100644 index cd4b59c7..00000000 --- a/Application/Data/Tests/Repository/AuthSessionRepositoryImplTests.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// AuthSessionRepositoryImplTests.swift -// DataTests -// -// Created by opfic on 6/8/26. -// - -import Combine -import Foundation -import Testing -@testable import Data - -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/Data/Tests/Repository/ProfileImageDataRepositoryImplTests.swift b/Application/Data/Tests/Repository/ProfileImageDataRepositoryImplTests.swift deleted file mode 100644 index ac0fe0e7..00000000 --- a/Application/Data/Tests/Repository/ProfileImageDataRepositoryImplTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// ProfileImageDataRepositoryImplTests.swift -// DataTests -// -// Created by opfic on 6/11/26. -// - -import Foundation -import Testing -@testable import Data - -struct ProfileImageDataRepositoryImplTests { - @Test("캐시가 있어도 원격 이미지 데이터를 다시 요청하고 성공 데이터를 저장한다") - func 캐시가_있어도_원격_이미지_데이터를_다시_요청하고_성공_데이터를_저장한다() async throws { - let cachedData = Data([1, 2, 3]) - let remoteData = Data([4, 5, 6]) - let service = ProfileImageDataServiceSpy(data: cachedData) - let store = ProfileImageMemoryCacheStoreSpy() - let repository = ProfileImageDataRepositoryImpl(service: service, store: store) - let url = URL(string: "https://example.com/avatar.png")! - - _ = try await repository.fetchImageData(from: url) - service.data = remoteData - let data = try await repository.fetchImageData(from: url) - - #expect(data == remoteData) - #expect(service.calledURLs == [url, url]) - #expect(store.storedData == remoteData) - } - - @Test("원격 이미지 요청 실패 시 메모리 캐시 데이터를 반환한다") - func 원격_이미지_요청_실패_시_메모리_캐시_데이터를_반환한다() async throws { - let cachedData = Data([1, 2, 3]) - let service = ProfileImageDataServiceSpy(data: cachedData) - let store = ProfileImageMemoryCacheStoreSpy() - let repository = ProfileImageDataRepositoryImpl(service: service, store: store) - let url = URL(string: "https://example.com/avatar.png")! - - _ = try await repository.fetchImageData(from: url) - service.error = ProfileImageDataRepositoryImplTestsError.serviceFailed - let data = try await repository.fetchImageData(from: url) - - #expect(data == cachedData) - #expect(service.calledURLs == [url, url]) - } -} - -private final class ProfileImageDataServiceSpy: ProfileImageDataService { - var data: Data - var error: Error? - private(set) var calledURLs: [URL] = [] - - init(data: Data) { - self.data = data - } - - func fetchImageData(from url: URL) async throws -> Data { - calledURLs.append(url) - - if let error { - throw error - } - - return data - } -} - -private final class ProfileImageMemoryCacheStoreSpy: MemoryCacheStore { - private var values = [String: Any]() - private(set) var storedData: Data? - - 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 - storedData = value as? Data - } -} - -private enum ProfileImageDataRepositoryImplTestsError: Error { - case serviceFailed -} diff --git a/Application/Data/Tests/Repository/TodoMutationEventBusImplTests.swift b/Application/Data/Tests/Repository/TodoMutationEventBusImplTests.swift deleted file mode 100644 index db540a33..00000000 --- a/Application/Data/Tests/Repository/TodoMutationEventBusImplTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// TodoMutationEventBusImplTests.swift -// DataTests -// -// Created by opfic on 6/6/26. -// - -import Combine -import Testing -import Domain -@testable import Data - -struct TodoMutationEventBusImplTests { - @Test("TodoMutationEventBus는 발행된 이벤트를 관찰자에게 전달한다") - func todoMutationEventBus는_발행된_이벤트를_관찰자에게_전달한다() { - let bus = TodoMutationEventBusImpl() - var events = [TodoMutationEvent]() - var cancellables = Set() - - bus.observe() - .sink { events.append($0) } - .store(in: &cancellables) - - bus.publish(.updated("todo-id")) - - #expect(events == [.updated("todo-id")]) - } -} diff --git a/Application/Data/Tests/Repository/TodoRepositoryImplTests.swift b/Application/Data/Tests/Repository/TodoRepositoryImplTests.swift deleted file mode 100644 index 2293516c..00000000 --- a/Application/Data/Tests/Repository/TodoRepositoryImplTests.swift +++ /dev/null @@ -1,242 +0,0 @@ -// -// TodoRepositoryImplTests.swift -// DataTests -// -// Created by opfic on 6/1/26. -// - -import Combine -import Foundation -import Testing -import Core -import Domain -@testable import Data - -struct TodoRepositoryImplTests { - @Test("Todo 변경 성공 시 mutation 이벤트를 발행한다") - func todo_변경_성공_시_mutation_이벤트를_발행한다() async throws { - let fixture = makeFixture() - let todo = makeTodo() - - try await fixture.repository.upsertTodo(todo) - try await fixture.repository.deleteTodo(todo.id) - try await fixture.repository.undoDeleteTodo(todo.id) - - let mutationEvents = fixture.todoMutationEventBus.publishedEvents() - #expect(mutationEvents == [.updated(todo.id), .deleted(todo.id), .restored(todo.id)]) - #expect(fixture.widgetSnapshotUpdater.upsertedTodoIds == [todo.id]) - #expect(fixture.widgetSnapshotUpdater.deletedTodoIds == [todo.id]) - #expect(fixture.widgetSnapshotUpdater.restoredTodoIds == [todo.id]) - } - - @Test("Todo 변경 실패 시 mutation 이벤트를 발행하지 않는다") - func todo_변경_실패_시_mutation_이벤트를_발행하지_않는다() async throws { - let fixture = makeFixture() - let todo = makeTodo() - - await fixture.todoService.setShouldFail(true) - - do { - try await fixture.repository.upsertTodo(todo) - Issue.record("upsertTodo should fail") - } catch { - #expect(error as? TodoRepositoryImplTestsError == .serviceFailed) - } - do { - try await fixture.repository.deleteTodo(todo.id) - Issue.record("deleteTodo should fail") - } catch { - #expect(error as? TodoRepositoryImplTestsError == .serviceFailed) - } - do { - try await fixture.repository.undoDeleteTodo(todo.id) - Issue.record("undoDeleteTodo should fail") - } catch { - #expect(error as? TodoRepositoryImplTestsError == .serviceFailed) - } - - let mutationEvents = fixture.todoMutationEventBus.publishedEvents() - #expect(mutationEvents.isEmpty) - #expect(fixture.widgetSnapshotUpdater.upsertedTodoIds.isEmpty) - #expect(fixture.widgetSnapshotUpdater.deletedTodoIds.isEmpty) - #expect(fixture.widgetSnapshotUpdater.restoredTodoIds.isEmpty) - } - - private func makeFixture() -> Fixture { - let todoService = TodoServiceSpy() - let todoCategoryService = TodoCategoryServiceSpy() - let store = TodoRepositoryMemoryCacheStoreSpy() - let widgetSnapshotUpdater = WidgetSnapshotUpdaterSpy() - let todoMutationEventBus = TodoMutationEventBusSpy() - let repository = TodoRepositoryImpl( - todoService: todoService, - todoCategoryService: todoCategoryService, - store: store, - updater: widgetSnapshotUpdater, - eventBus: todoMutationEventBus - ) - - return Fixture( - repository: repository, - todoService: todoService, - widgetSnapshotUpdater: widgetSnapshotUpdater, - todoMutationEventBus: todoMutationEventBus - ) - } - - private func makeTodo() -> Todo { - Todo( - id: "todo-id", - isPinned: false, - isCompleted: false, - isChecked: false, - number: 1, - title: "title", - content: "content", - createdAt: .now, - updatedAt: .now, - completedAt: nil, - deletedAt: nil, - dueDate: nil, - tags: [], - category: .system(.feature) - ) - } -} - -private struct Fixture { - let repository: TodoRepositoryImpl - let todoService: TodoServiceSpy - let widgetSnapshotUpdater: WidgetSnapshotUpdaterSpy - let todoMutationEventBus: TodoMutationEventBusSpy -} - -private actor TodoServiceSpy: TodoService { - private var shouldFail = false - - func setShouldFail(_ shouldFail: Bool) { - self.shouldFail = shouldFail - } - - func fetchTodos(_ query: TodoQuery, cursor: TodoCursorDTO?) async throws -> TodoPageResponse { - throw TodoRepositoryImplTestsError.unexpectedCall - } - - func upsertTodo(request: TodoRequest) async throws { - guard shouldFail == false else { - throw TodoRepositoryImplTestsError.serviceFailed - } - } - - func deleteTodo(todoId: String) async throws { - guard shouldFail == false else { - throw TodoRepositoryImplTestsError.serviceFailed - } - } - - func undoDeleteTodo(todoId: String) async throws { - guard shouldFail == false else { - throw TodoRepositoryImplTestsError.serviceFailed - } - } - - func fetchTodo(todoId: String) async throws -> TodoResponse { - throw TodoRepositoryImplTestsError.unexpectedCall - } - - func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] { - throw TodoRepositoryImplTestsError.unexpectedCall - } -} - -private struct TodoCategoryServiceSpy: TodoCategoryService { - func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] { - [] - } - - 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 TodoMutationEventBusSpy: TodoMutationEventBus { - private var capturedEvents = [TodoMutationEvent]() - - func publish(_ event: TodoMutationEvent) { - capturedEvents.append(event) - } - - func publishedEvents() -> [TodoMutationEvent] { - capturedEvents - } - - func observe() -> AnyPublisher { - Empty().eraseToAnyPublisher() - } -} - -private final class WidgetSnapshotUpdaterSpy: WidgetSnapshotUpdater { - private(set) var upsertedTodoIds = [String]() - private(set) var deletedTodoIds = [String]() - private(set) var restoredTodoIds = [String]() - - func updateTodaySnapshot( - todos: [WidgetTodoSnapshot]?, - displayOptions: TodayDisplayOptions?, - now: Date - ) { } - - func updateHeatmapSnapshot( - createdTodos: [WidgetTodoSnapshot]?, - completedTodos: [WidgetTodoSnapshot]?, - deletedTodos: [WidgetTodoSnapshot]?, - quarterStart: Date?, - now: Date - ) { } - - func upsertTodoSnapshot( - _ todo: WidgetTodoSnapshot, - now: Date - ) { - upsertedTodoIds.append(todo.id) - } - - func deleteTodoSnapshot( - todoId: String, - deletedAt: Date, - now: Date - ) { - deletedTodoIds.append(todoId) - } - - func restoreTodoSnapshot( - todoId: String, - now: Date - ) { - restoredTodoIds.append(todoId) - } - - func clear() { } -} - -private enum TodoRepositoryImplTestsError: Error, Equatable { - case serviceFailed - case unexpectedCall -} diff --git a/Application/Data/Tests/Repository/UserPreferencesRepositoryImplTests.swift b/Application/Data/Tests/Repository/UserPreferencesRepositoryImplTests.swift deleted file mode 100644 index c8c7be05..00000000 --- a/Application/Data/Tests/Repository/UserPreferencesRepositoryImplTests.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// UserPreferencesRepositoryImplTests.swift -// DataTests -// -// Created by opfic on 6/1/26. -// - -import Combine -import Foundation -import Testing -import Core -@testable import Data - -struct UserPreferencesRepositoryImplTests { - @Test("위젯 설정 변경 시 위젯 스냅샷 재생성 이벤트를 발행한다") - func 위젯_설정_변경_시_위젯_스냅샷_재생성_이벤트를_발행한다() { - let widgetSnapshotPreferenceStore = WidgetSnapshotPreferenceStoreSpy() - let widgetSyncEventBus = WidgetSyncEventBusSpy() - let repository = UserPreferencesRepositoryImpl( - store: UserDefaultsStoreSpy(), - themeStore: ThemeStoreSpy(), - widgetSnapshotPreferenceStore: widgetSnapshotPreferenceStore, - widgetSyncEventBus: widgetSyncEventBus - ) - - repository.setHeatmapActivityTypes(["created", "deleted"]) - repository.setTodayDisplayOptions( - TodayDisplayOptions( - dueDateVisibility: .withDueDateOnly, - focusVisibility: .focusedOnly - ) - ) - - #expect(widgetSnapshotPreferenceStore.heatmapActivityTypesValue == ["created", "deleted"]) - #expect( - widgetSnapshotPreferenceStore.todayDisplayOptionsValue == TodayDisplayOptions( - dueDateVisibility: .withDueDateOnly, - focusVisibility: .focusedOnly - ) - ) - #expect(widgetSyncEventBus.events == [.refreshRequested, .refreshRequested]) - } -} - -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 - } - - func setString(_ value: String?, forKey key: String) { } - - func stringArray(forKey key: String) -> [String] { - [] - } - - func setStringArray(_ value: [String], forKey key: String) { } - - func bool(forKey key: String) -> Bool { - false - } - - func setBool(_ value: Bool, forKey key: String) { } -} - -private final class ThemeStoreSpy: ThemeStore { - func observeTheme() -> AnyPublisher { - Empty().eraseToAnyPublisher() - } - - func send(_ theme: SystemTheme) { } -} - -private final class WidgetSnapshotPreferenceStoreSpy: WidgetSnapshotPreferenceStore { - private(set) var heatmapActivityTypesValue = [String]() - private(set) var todayDisplayOptionsValue = TodayDisplayOptions.default - - func heatmapActivityTypes() -> [String] { - heatmapActivityTypesValue - } - - func setHeatmapActivityTypes(_ activityTypes: [String]) { - heatmapActivityTypesValue = activityTypes - } - - func selectedActivityKinds() -> Set { - [] - } - - func todayDisplayOptions() -> TodayDisplayOptions { - todayDisplayOptionsValue - } - - func setTodayDisplayOptions(_ options: TodayDisplayOptions) { - todayDisplayOptionsValue = options - } - - func clear() { } -} - -private final class WidgetSyncEventBusSpy: WidgetSyncEventBus { - private(set) var events = [WidgetSyncEvent]() - - func publish(_ event: WidgetSyncEvent) { - events.append(event) - } - - func observe() -> AnyPublisher { - Empty().eraseToAnyPublisher() - } -} diff --git a/Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift b/Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift new file mode 100644 index 00000000..01c0396f --- /dev/null +++ b/Application/Data/Tests/Repository/WebPageRepositoryImplTests.swift @@ -0,0 +1,226 @@ +// +// WebPageRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 7/8/26. +// + +import Combine +import Foundation +import Testing +import Domain +@testable import Data + +struct WebPageRepositoryImplTests { + @Test("웹페이지 저장은 현재 계정 scope로 메타데이터 캐시를 생성한다") + func 웹페이지_저장은_현재_계정_scope로_메타데이터_캐시를_생성한다() async throws { + let expectedImageURL = URL(fileURLWithPath: "/account-a/webPageImages/image.jpeg") + let fixture = makeFixture( + uid: "account-a", + expectedImageURL: expectedImageURL + ) + let urlString = "https://example.com/article" + + try await fixture.repository.upsert(urlString) + + #expect(await fixture.metadataService.fetchMetadataRequests() == [ + WebPageMetadataServiceRequest(urlString: urlString, accountID: "account-a") + ]) + let requests = await fixture.webPageService.upsertedRequests() + let request = try #require(requests.first) + #expect(requests.count == 1) + #expect(request.title == "metadata-title") + #expect(request.url == urlString) + #expect(request.displayURL == urlString) + #expect(request.imageURL == expectedImageURL.absoluteString) + #expect(request.isDeleted == false) + } + + @Test("웹페이지 삭제는 현재 계정 scope의 캐시만 제거한다") + func 웹페이지_삭제는_현재_계정_scope의_캐시만_제거한다() async throws { + let fixture = makeFixture(uid: "account-a") + let urlString = "https://example.com/article" + + try await fixture.repository.delete(id: "web-page-id", urlString: urlString) + + #expect(await fixture.webPageService.deletedIDs() == ["web-page-id"]) + #expect(await fixture.metadataService.removeCachedImageRequests() == [ + WebPageMetadataServiceRequest(urlString: urlString, accountID: "account-a") + ]) + } + + @Test("현재 계정 scope와 다른 파일 캐시는 재사용하지 않고 복구한다") + func 현재_계정_scope와_다른_파일_캐시는_재사용하지_않고_복구한다() async throws { + let expectedImageURL = URL(fileURLWithPath: "/account-a/webPageImages/image.jpeg") + let legacyImageURL = URL(fileURLWithPath: "/legacy/webPageImages/image.jpeg") + let urlString = "https://example.com/article" + let response = WebPageResponse( + id: "web-page-id", + title: "legacy-title", + url: urlString, + displayURL: urlString, + imageURL: legacyImageURL.absoluteString + ) + let fixture = makeFixture( + uid: "account-a", + responses: [response], + expectedImageURL: expectedImageURL + ) + + let pages = try await fixture.repository.fetch("") + + #expect(await fixture.metadataService.cachedImageURLRequests() == [ + WebPageMetadataServiceRequest(urlString: urlString, accountID: "account-a") + ]) + #expect(await fixture.metadataService.fetchMetadataRequests() == [ + WebPageMetadataServiceRequest(urlString: urlString, accountID: "account-a") + ]) + let requests = await fixture.webPageService.upsertedRequests() + let request = try #require(requests.first) + let page = try #require(pages.first) + #expect(requests.count == 1) + #expect(request.title == "metadata-title") + #expect(request.url == urlString) + #expect(request.displayURL == urlString) + #expect(request.imageURL == expectedImageURL.absoluteString) + #expect(page.imageURL == expectedImageURL) + } + + private func makeFixture( + uid: String?, + responses: [WebPageResponse] = [], + expectedImageURL: URL = URL(fileURLWithPath: "/account/webPageImages/image.jpeg") + ) -> WebPageRepositoryFixture { + let authService = WebPageRepositoryAuthServiceSpy(uid: uid) + let metadataService = WebPageMetadataServiceSpy( + response: WebPageMetadataResponse( + title: "metadata-title", + displayURL: "https://example.com/article", + imageURL: expectedImageURL.absoluteString + ), + expectedImageURL: expectedImageURL + ) + let webPageService = WebPageServiceSpy(responses: responses) + let repository = WebPageRepositoryImpl( + authService: authService, + metadataService: metadataService, + webPageService: webPageService + ) + + return WebPageRepositoryFixture( + metadataService: metadataService, + repository: repository, + webPageService: webPageService + ) + } +} + +private struct WebPageRepositoryFixture { + let metadataService: WebPageMetadataServiceSpy + let repository: WebPageRepositoryImpl + let webPageService: WebPageServiceSpy +} + +private struct WebPageMetadataServiceRequest: Equatable { + let urlString: String + let accountID: String? +} + +private final class WebPageRepositoryAuthServiceSpy: AuthService { + private let subject: CurrentValueSubject + + var uid: String? + var providerIDs: [String] { [] } + var currentUserEmail: String? { nil } + var providerCount: Int { 0 } + + init(uid: String?) { + self.uid = uid + self.subject = CurrentValueSubject(uid != nil) + } + + 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 { } +} + +private actor WebPageMetadataServiceSpy: WebPageMetadataService { + private let expectedImageURL: URL + private let response: WebPageMetadataResponse + private var cachedImageURLEvents = [WebPageMetadataServiceRequest]() + private var fetchMetadataEvents = [WebPageMetadataServiceRequest]() + private var removeCachedImageEvents = [WebPageMetadataServiceRequest]() + + init( + response: WebPageMetadataResponse, + expectedImageURL: URL + ) { + self.expectedImageURL = expectedImageURL + self.response = response + } + + func fetchMetadata(from urlString: String, accountID: String?) async throws -> WebPageMetadataResponse { + fetchMetadataEvents.append(WebPageMetadataServiceRequest(urlString: urlString, accountID: accountID)) + return response + } + + func removeCachedImage(for urlString: String, accountID: String?) async { + removeCachedImageEvents.append(WebPageMetadataServiceRequest(urlString: urlString, accountID: accountID)) + } + + func cachedImageURL(for urlString: String, accountID: String?) async throws -> URL { + cachedImageURLEvents.append(WebPageMetadataServiceRequest(urlString: urlString, accountID: accountID)) + return expectedImageURL + } + + func cachedImageURLRequests() -> [WebPageMetadataServiceRequest] { + cachedImageURLEvents + } + + func fetchMetadataRequests() -> [WebPageMetadataServiceRequest] { + fetchMetadataEvents + } + + func removeCachedImageRequests() -> [WebPageMetadataServiceRequest] { + removeCachedImageEvents + } +} + +private actor WebPageServiceSpy: WebPageService { + private let responses: [WebPageResponse] + private var deleted = [String]() + private var upserted = [WebPageRequest]() + + init(responses: [WebPageResponse] = []) { + self.responses = responses + } + + func fetchWebPages(_ query: String) async throws -> [WebPageResponse] { + responses + } + + func upsertWebPage(_ request: WebPageRequest) async throws { + upserted.append(request) + } + + func deleteWebPage(_ id: String) async throws { + deleted.append(id) + } + + func undoDeleteWebPage(_ id: String) async throws { } + + func deletedIDs() -> [String] { + deleted + } + + func upsertedRequests() -> [WebPageRequest] { + upserted + } +} diff --git a/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift b/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift deleted file mode 100644 index 5b2ca360..00000000 --- a/Application/Data/Tests/Repository/WidgetTodoSnapshotRepositoryImplTests.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// WidgetTodoSnapshotRepositoryImplTests.swift -// DataTests -// -// Created by opfic on 6/8/26. -// - -import Foundation -import Testing -import Core -import Domain -@testable import Data - -struct WidgetTodoSnapshotRepositoryImplTests { - @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 = makeTodoResponse(id: "today", createdAt: now, dueDate: now) - - await todoServiceSpy.setTodos([todo], for: .dueDate) - - let snapshots = try await repository.fetchTodayTodos( - dueDateFilter: .withDueDate, - sortTarget: .dueDate, - sortOrder: .oldest, - pageSize: 100 - ) - let queries = await todoServiceSpy.calledQueries() - - #expect(snapshots == [makeSnapshot(id: "today", createdAt: now, dueDate: now)]) - #expect(queries == [ - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: .withDueDate, - sortTarget: .dueDate, - sortOrder: .oldest, - pageSize: 100, - fetchAllPages: true - ) - ]) - } - - @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 = makeTodoResponse(id: "created", createdAt: quarterStart) - - await todoServiceSpy.setTodos([todo], for: .createdAt) - - let snapshots = try await repository.fetchHeatmapTodos( - sortTarget: .createdAt, - quarterStart: quarterStart, - nextQuarterStart: nextQuarterStart, - pageSize: 100 - ) - let queries = await todoServiceSpy.calledQueries() - - #expect(snapshots == [makeSnapshot(id: "created", createdAt: quarterStart)]) - #expect(queries == [ - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: .createdAt, - pageSize: 100, - fetchAllPages: true - ) - ]) - } - - private func makeSnapshot( - id: String, - createdAt: Date, - completedAt: Date? = nil, - deletedAt: Date? = nil, - dueDate: Date? = nil - ) -> WidgetTodoSnapshot { - WidgetTodoSnapshot( - id: id, - number: 1, - title: id, - isPinned: false, - createdAt: createdAt, - completedAt: completedAt, - deletedAt: deletedAt, - dueDate: dueDate - ) - } - - private func makeTodoResponse( - id: String, - createdAt: Date, - completedAt: Date? = nil, - deletedAt: Date? = nil, - dueDate: Date? = nil - ) -> TodoResponse { - TodoResponse( - id: id, - isPinned: false, - isCompleted: completedAt != nil, - isChecked: false, - number: 1, - title: id, - content: "", - createdAt: createdAt, - updatedAt: createdAt, - completedAt: completedAt, - deletedAt: deletedAt, - dueDate: dueDate, - tags: [], - category: .raw(SystemTodoCategory.feature.rawValue) - ) - } -} - -private actor WidgetTodoSnapshotTodoServiceSpy: TodoService { - private var queries = [TodoQuery]() - private var todosBySortTarget = [TodoQuery.SortTarget: [TodoResponse]]() - - func setTodos(_ todos: [TodoResponse], for sortTarget: TodoQuery.SortTarget) { - todosBySortTarget[sortTarget] = todos - } - - func fetchTodos(_ query: TodoQuery, cursor: TodoCursorDTO?) async throws -> TodoPageResponse { - queries.append(query) - - return TodoPageResponse( - items: todosBySortTarget[query.sortTarget] ?? [], - nextCursor: nil - ) - } - - func upsertTodo(request: TodoRequest) async throws { - throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall - } - - func deleteTodo(todoId: String) async throws { - throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall - } - - func undoDeleteTodo(todoId: String) async throws { - throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall - } - - func fetchTodo(todoId: String) async throws -> TodoResponse { - throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall - } - - func fetchReferences(_ numbers: [Int]) async throws -> [Int: TodoReferenceResponse] { - throw WidgetTodoSnapshotTodoServiceSpyError.unexpectedCall - } - - func calledQueries() -> [TodoQuery] { - queries - } -} - -private enum WidgetTodoSnapshotTodoServiceSpyError: Error { - case unexpectedCall -} diff --git a/Application/Data/Tests/Widget/WidgetSyncEventTests.swift b/Application/Data/Tests/Widget/WidgetSyncEventTests.swift deleted file mode 100644 index a2e16a90..00000000 --- a/Application/Data/Tests/Widget/WidgetSyncEventTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// WidgetSyncEventTests.swift -// DataTests -// -// Created by opfic on 4/29/26. -// - -import Foundation -import Testing -@testable import Data - -struct WidgetSyncEventTests { - @Test("위젯 동기화 이벤트는 동기화 요청만 표현한다") - func 위젯_동기화_이벤트는_동기화_요청만_표현한다() { - #expect(WidgetSyncEvent.syncRequested == .syncRequested) - } -} diff --git a/Application/Domain/Project.swift b/Application/Domain/Project.swift index decf342e..54316598 100644 --- a/Application/Domain/Project.swift +++ b/Application/Domain/Project.swift @@ -9,7 +9,7 @@ let project = Project.devlogFramework( testsInfoPlistPath: "../Shared/InfoPlists/UnitTests-Info.plist", packages: DevLogPackages.defaultPackages, dependencies: [ - .project(target: "Core", path: "../Core"), + .project(target: "Core", path: "../Core") ], - hasTests: true + hasTests: false ) diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift deleted file mode 100644 index ff8be08c..00000000 --- a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// FunctionAPIEndpointTests.swift -// InfraTests -// -// Created by opfic on 7/3/26. -// - -import Testing -@testable import Infra - -struct FunctionAPIEndpointTests { - @Test("WebPage 삭제 endpoint는 Firestore document id를 재인코딩하지 않는다") - func WebPage_삭제_endpoint는_Firestore_document_id를_재인코딩하지_않는다() { - let id = "https%3A%2F%2Fgithub%2Ecom%2Fopficdev" - let endpoint = FunctionAPIEndpoint.requestWebPageDeletion(id) - - #expect(endpoint.path == "/web-pages/\(id)/deletion-request") - } - - @Test("WebPage 삭제 복구 endpoint는 Firestore document id를 재인코딩하지 않는다") - func WebPage_삭제_복구_endpoint는_Firestore_document_id를_재인코딩하지_않는다() { - let id = "https%3A%2F%2Fgithub%2Ecom%2Fopficdev" - let endpoint = FunctionAPIEndpoint.undoWebPageDeletion(id) - - #expect(endpoint.path == "/web-pages/\(id)/deletion-request") - } -} diff --git a/Application/Persistence/Tests/Persistence/WebPageImageStoreImplTests.swift b/Application/Persistence/Tests/Persistence/WebPageImageStoreImplTests.swift index 81572200..edbef27e 100644 --- a/Application/Persistence/Tests/Persistence/WebPageImageStoreImplTests.swift +++ b/Application/Persistence/Tests/Persistence/WebPageImageStoreImplTests.swift @@ -11,115 +11,42 @@ import Testing @Suite(.serialized) struct WebPageImageStoreImplTests { - @Test("웹페이지 이미지는 저장되고 삭제된다") - func 웹페이지_이미지는_저장되고_삭제된다() async throws { + @Test("웹페이지 이미지 캐시는 계정별로 분리되고 현재 계정 삭제는 다른 계정을 유지한다") + func 웹페이지_이미지_캐시는_계정별로_분리되고_현재_계정_삭제는_다른_계정을_유지한다() async throws { let store = WebPageImageStoreImpl() let fileManager = FileManager.default - try await store.clearDirectory() let url = try #require(URL(string: "https://example.com/image")) - let data = Data("image-data".utf8) + let firstAccountID = "account-a" + let secondAccountID = "account-b" - let fileURL = try await store.saveImage(data, for: url) + try await store.clearDirectory(accountID: firstAccountID) + try await store.clearDirectory(accountID: secondAccountID) - let savedData = try #require(fileManager.contents(atPath: fileURL.path)) - let directorySize = await store.dirSizeInBytes() - #expect(fileURL.deletingLastPathComponent().lastPathComponent == "webPageImages") - #expect(savedData == data) - #expect(0 < directorySize) - - let removed = try await store.removeImage(for: url) - let removedAgain = try await store.removeImage(for: url) - - #expect(removed) - #expect(!removedAgain) - #expect(!fileManager.fileExists(atPath: fileURL.path)) - - try await store.clearDirectory() - } - - @Test("웹페이지 이미지 디렉터리 삭제는 저장된 이미지를 모두 제거한다") - func 웹페이지_이미지_디렉터리_삭제는_저장된_이미지를_모두_제거한다() async throws { - let store = WebPageImageStoreImpl() - try await store.clearDirectory() - let firstURL = try #require(URL(string: "https://example.com/first")) - let secondURL = try #require(URL(string: "https://example.com/second")) - - _ = try await store.saveImage(Data("first".utf8), for: firstURL) - _ = try await store.saveImage(Data("second".utf8), for: secondURL) - try await store.clearDirectory() - - let directorySize = await store.dirSizeInBytes() - #expect(directorySize == 0) - } - - @Test("같은 store를 공유하는 객체의 저장은 트랜잭션 단위로 반영된다") - func 같은_store를_공유하는_객체의_저장은_트랜잭션_단위로_반영된다() async throws { - let store = WebPageImageStoreImpl() - let firstClient = WebPageImageStoreClient(store: store) - let secondClient = WebPageImageStoreClient(store: store) - try await store.clearDirectory() - let url = try #require(URL(string: "https://example.com/\(UUID().uuidString)")) - let largeData = Data(repeating: 1, count: 64 * 1024 * 1024) - let smallData = Data("latest".utf8) - let startedAt = ContinuousClock.now - - // 동일한 Impl 인스턴스를 공유하는 두 객체가 접근하는 형태의 결과 기반 테스트다. - // 첫 번째 큰 저장 작업이 먼저 큐에 들어갈 시간을 준 뒤 두 번째 작은 저장을 요청하고, - // 최종 파일이 작은 데이터라면 앞 작업 전체가 끝난 뒤 뒤 작업이 반영된 것으로 본다. - // 각 작업의 완료 시점은 호출자 관점에서 saveImage await가 반환된 시점으로 기록한다. - let largeSaveTask = Task { - try await firstClient.saveImage(largeData, for: url, name: "large", since: startedAt) - } - try await Task.sleep(nanoseconds: 10_000_000) - - let smallSaveMeasurement = try await secondClient.saveImage(smallData, for: url, name: "small", since: startedAt) - let largeSaveMeasurement = try await largeSaveTask.value - let savedData = try Data(contentsOf: smallSaveMeasurement.fileURL) + let firstFileURL = try await store.saveImage( + Data("first-account".utf8), + for: url, + accountID: firstAccountID + ) + let secondFileURL = try await store.saveImage( + Data("second-account".utf8), + for: url, + accountID: secondAccountID + ) - print(saveSummary(largeSaveMeasurement)) - print(saveSummary(smallSaveMeasurement)) + #expect(firstFileURL.lastPathComponent == secondFileURL.lastPathComponent) + #expect(firstFileURL.deletingLastPathComponent() != secondFileURL.deletingLastPathComponent()) + #expect(fileManager.fileExists(atPath: firstFileURL.path)) + #expect(fileManager.fileExists(atPath: secondFileURL.path)) - #expect(savedData == smallData) + try await store.clearDirectory(accountID: firstAccountID) - try await store.clearDirectory() - } -} + let firstDirectorySize = await store.dirSizeInBytes(accountID: firstAccountID) + let secondDirectorySize = await store.dirSizeInBytes(accountID: secondAccountID) + #expect(firstDirectorySize == 0) + #expect(0 < secondDirectorySize) + #expect(!fileManager.fileExists(atPath: firstFileURL.path)) + #expect(fileManager.fileExists(atPath: secondFileURL.path)) -private struct WebPageImageStoreClient { - let store: WebPageImageStoreImpl - - func saveImage( - _ data: Data, - for url: URL, - name: String, - since startedAt: ContinuousClock.Instant - ) async throws -> WebPageImageStoreSaveMeasurement { - let requestedAt = startedAt.duration(to: .now) - let fileURL = try await store.saveImage(data, for: url) - let finishedAt = startedAt.duration(to: .now) - - return WebPageImageStoreSaveMeasurement( - name: name, - fileURL: fileURL, - requestedAt: requestedAt, - finishedAt: finishedAt - ) + try await store.clearDirectory(accountID: secondAccountID) } } - -private struct WebPageImageStoreSaveMeasurement { - let name: String - let fileURL: URL - let requestedAt: Duration - let finishedAt: Duration -} - -private func saveSummary(_ measurement: WebPageImageStoreSaveMeasurement) -> String { - "\(measurement.name) save requested: \(millisecondsString(measurement.requestedAt))ms, finished: \(millisecondsString(measurement.finishedAt))ms" -} - -private func millisecondsString(_ duration: Duration) -> String { - let components = duration.components - let milliseconds = Double(components.seconds) * 1_000 + Double(components.attoseconds) / 1_000_000_000_000_000 - return String(format: "%.3f", milliseconds) -} diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSpies.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSpies.swift index 40ad683b..37c9103f 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSpies.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListTestSpies.swift @@ -5,6 +5,7 @@ // Created by opfic on 7/6/26. // +import Core import Domain final class DeletePushNotificationUseCaseSpy: DeletePushNotificationUseCase { diff --git a/Application/Widget/Tests/Widget/WidgetSnapshotPreferenceStoreTests.swift b/Application/Widget/Tests/Widget/WidgetSnapshotPreferenceStoreTests.swift deleted file mode 100644 index 6c4de007..00000000 --- a/Application/Widget/Tests/Widget/WidgetSnapshotPreferenceStoreTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// WidgetSnapshotPreferenceStoreTests.swift -// WidgetTests -// -// Created by opfic on 4/30/26. -// - -import Foundation -import Core -import Testing -@testable import Widget - -struct WidgetSnapshotPreferenceStoreTests { - @Test("Heatmap activity kind 설정이 비어 있으면 전체 kind를 사용한다") - func heatmap_activity_kind_설정이_비어_있으면_전체_kind를_사용한다() { - let fixture = makeFixture() - - #expect(fixture.widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .completed, .deleted])) - } - - @Test("Heatmap activity kind 설정에 유효하지 않은 값만 있으면 전체 kind를 사용한다") - func heatmap_activity_kind_설정에_유효하지_않은_값만_있으면_전체_kind를_사용한다() { - let fixture = makeFixture() - - fixture.widgetSnapshotPreferenceStore.setHeatmapActivityTypes(["unknown"]) - - #expect(fixture.widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .completed, .deleted])) - } - - @Test("Heatmap activity kind 설정은 유효한 값만 유지한다") - func heatmap_activity_kind_설정은_유효한_값만_유지한다() { - let fixture = makeFixture() - - fixture.widgetSnapshotPreferenceStore.setHeatmapActivityTypes(["created", "unknown", "deleted", "created"]) - - #expect(fixture.widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .deleted])) - } - - @Test("Today display option 설정이 깨져 있으면 기본 옵션을 사용한다") - func today_display_option_설정이_깨져_있으면_기본_옵션을_사용한다() { - let fixture = makeFixture() - - fixture.userDefaults.set("invalid", forKey: "Today.dueDateVisibility") - fixture.userDefaults.set("invalid", forKey: "Today.focusVisibility") - - #expect(fixture.widgetSnapshotPreferenceStore.todayDisplayOptions() == .default) - } - - private func makeFixture() -> ( - widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStoreImpl, - userDefaults: UserDefaults - ) { - let suiteName = "WidgetSnapshotPreferenceStoreTests.\(UUID().uuidString)" - let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard - userDefaults.removePersistentDomain(forName: suiteName) - let widgetSnapshotPreferenceStore = WidgetSnapshotPreferenceStoreImpl(userDefaults: userDefaults) - return (widgetSnapshotPreferenceStore, userDefaults) - } -} diff --git a/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift b/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift index 197527a8..7c3dc467 100644 --- a/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift +++ b/Application/Widget/Tests/Widget/WidgetSnapshotUpdaterTests.swift @@ -75,51 +75,6 @@ struct WidgetSnapshotUpdaterTests { #expect(snapshot.maxCount == 1) } - @Test("WidgetSnapshotUpdaterImpl는 모든 위젯 스냅샷을 삭제한다") - func widgetSnapshotUpdater는_모든_위젯_스냅샷을_삭제한다() throws { - let calendar = Calendar(identifier: .gregorian) - let quarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) - let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 30))) - let fixture = makeFixture(calendar: calendar) - let todo = makeTodo( - id: "today", - createdAt: now, - dueDate: now - ) - fixture.preferenceStore.setHeatmapActivityTypes(["created"]) - fixture.preferenceStore.setTodayDisplayOptions( - TodayDisplayOptions( - dueDateVisibility: .withDueDateOnly, - focusVisibility: .focusedOnly - ) - ) - - fixture.updater.updateTodaySnapshot( - todos: [todo], - displayOptions: .default, - now: now - ) - fixture.updater.updateHeatmapSnapshot( - createdTodos: [ - makeTodo( - id: "created", - createdAt: now - ) - ], - completedTodos: [], - deletedTodos: [], - quarterStart: quarterStart, - now: now - ) - - fixture.updater.clear() - - #expect(try fixture.snapshotStore.loadTodaySnapshot() == nil) - #expect(try fixture.snapshotStore.loadHeatmapSnapshot() == nil) - #expect(fixture.preferenceStore.heatmapActivityTypes().isEmpty) - #expect(fixture.preferenceStore.todayDisplayOptions() == .default) - } - @Test("저장된 원본 재생성은 네트워크 fetch 없이 현재 설정을 반영한다") func 저장된_원본_재생성은_네트워크_fetch_없이_현재_설정을_반영한다() throws { let now = try #require(Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 30))) diff --git a/Application/Widget/Tests/Widget/WidgetSyncEventBusTests.swift b/Application/Widget/Tests/Widget/WidgetSyncEventBusTests.swift deleted file mode 100644 index c1871e58..00000000 --- a/Application/Widget/Tests/Widget/WidgetSyncEventBusTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// WidgetSyncEventBusTests.swift -// WidgetTests -// -// Created by opfic on 4/30/26. -// - -import Combine -import Testing -import Data -@testable import Widget - -struct WidgetSyncEventBusTests { - @Test("WidgetSyncEventBus는 발행된 이벤트를 관찰자에게 전달한다") - func widgetSyncEventBus는_발행된_이벤트를_관찰자에게_전달한다() { - let bus = WidgetSyncEventBusImpl() - var receivedEvents = [WidgetSyncEvent]() - let cancellable = bus.observe() - .sink { event in - receivedEvents.append(event) - } - - bus.publish(.syncRequested) - - #expect(receivedEvents == [.syncRequested]) - _ = cancellable - } -} diff --git a/Widget/WidgetCore/Tests/Common/WidgetSharedConstantsTests.swift b/Widget/WidgetCore/Tests/Common/WidgetSharedConstantsTests.swift deleted file mode 100644 index d82bbc2c..00000000 --- a/Widget/WidgetCore/Tests/Common/WidgetSharedConstantsTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// WidgetSharedConstantsTests.swift -// WidgetCoreTests -// -// Created by opfic on 4/29/26. -// - -import Testing -@testable import WidgetCore - -struct WidgetSharedConstantsTests { - @Test("위젯 kind와 snapshot key는 공유 상수로 관리한다") - func 위젯_kind와_snapshot_key는_공유_상수로_관리한다() { - #expect(WidgetKind.todayTodo == "TodayTodoWidget") - #expect(WidgetKind.heatmap == "HeatmapWidget") - #expect(WidgetSnapshotKey.today == "Widget.today.snapshot") - #expect(WidgetSnapshotKey.heatmap == "Widget.heatmap.snapshot") - } -}