diff --git a/DevLog.xcodeproj/project.pbxproj b/DevLog.xcodeproj/project.pbxproj index 9bac419a..6342b918 100644 --- a/DevLog.xcodeproj/project.pbxproj +++ b/DevLog.xcodeproj/project.pbxproj @@ -20,7 +20,18 @@ DFF2DACE2EDC02AD00778738 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = DFF2DACD2EDC02AD00778738 /* OrderedCollections */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + DF34164B2E45F67C00F9312B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DFD48AF82DC4D6E2005905C5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DFD48AFF2DC4D6E2005905C5; + remoteInfo = DevLog; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + DF3416492E45F67C00F9312B /* DevLog_Unit.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DevLog_Unit.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DFD48B002DC4D6E2005905C5 /* DevLog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DevLog.app; sourceTree = BUILT_PRODUCTS_DIR; }; DFD6453F2EC827A10073E133 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; DFD74E2E2E423EA700613803 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -38,6 +49,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + DF34164A2E45F67C00F9312B /* DevLog_Unit */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = DevLog_Unit; + sourceTree = ""; + }; DF8AB7982E938B0B00E50BBF /* DevLog */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -49,6 +65,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + DF3416472E45F67C00F9312B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD48AFD2DC4D6E2005905C5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -73,6 +96,7 @@ children = ( DFD6453F2EC827A10073E133 /* .gitignore */, DF8AB7982E938B0B00E50BBF /* DevLog */, + DF34164A2E45F67C00F9312B /* DevLog_Unit */, DFD74E2E2E423EA700613803 /* README.md */, DFE28EB62DCCF26300B28FE5 /* Frameworks */, DFD48B012DC4D6E2005905C5 /* Products */, @@ -82,6 +106,7 @@ DFD48B012DC4D6E2005905C5 /* Products */ = { isa = PBXGroup; children = ( + DF3416492E45F67C00F9312B /* DevLog_Unit.xctest */, DFD48B002DC4D6E2005905C5 /* DevLog.app */, ); name = Products; @@ -97,6 +122,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + DF3416442E45F67C00F9312B /* DevLog_Unit */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF3416452E45F67C00F9312B /* Build configuration list for PBXNativeTarget "DevLog_Unit" */; + buildPhases = ( + DF3416462E45F67C00F9312B /* Sources */, + DF3416472E45F67C00F9312B /* Frameworks */, + DF3416482E45F67C00F9312B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DF34164C2E45F67C00F9312B /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DF34164A2E45F67C00F9312B /* DevLog_Unit */, + ); + name = DevLog_Unit; + packageProductDependencies = ( + ); + productName = DevLog_Unit; + productReference = DF3416492E45F67C00F9312B /* DevLog_Unit.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; DFD48AFF2DC4D6E2005905C5 /* DevLog */ = { isa = PBXNativeTarget; buildConfigurationList = DFD48B112DC4D6E4005905C5 /* Build configuration list for PBXNativeTarget "DevLog" */; @@ -139,6 +187,10 @@ LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 2600; TargetAttributes = { + DF3416442E45F67C00F9312B = { + CreatedOnToolsVersion = 16.3; + TestTargetID = DFD48AFF2DC4D6E2005905C5; + }; DFD48AFF2DC4D6E2005905C5 = { CreatedOnToolsVersion = 16.3; }; @@ -165,12 +217,20 @@ projectDirPath = ""; projectRoot = ""; targets = ( + DF3416442E45F67C00F9312B /* DevLog_Unit */, DFD48AFF2DC4D6E2005905C5 /* DevLog */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + DF3416482E45F67C00F9312B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD48AFE2DC4D6E2005905C5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -183,6 +243,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + DF3416462E45F67C00F9312B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DFD48AFC2DC4D6E2005905C5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -193,6 +260,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + DF34164C2E45F67C00F9312B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DFD48AFF2DC4D6E2005905C5 /* DevLog */; + targetProxy = DF34164B2E45F67C00F9312B /* PBXContainerItemProxy */; + }; DF66A07D2EA52E9F0098E643 /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = DF66A07C2EA52E9F0098E643 /* SwiftLintBuildToolPlugin */; @@ -200,6 +272,56 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + DF34164D2E45F67C00F9312B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4CPC6N38WA; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog_Unit; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DevLog.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DevLog"; + TEST_TARGET_NAME = DevLog; + }; + name = Debug; + }; + DF34164E2E45F67C00F9312B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4CPC6N38WA; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog_Unit; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DevLog.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DevLog"; + TEST_TARGET_NAME = DevLog; + }; + name = Release; + }; DFD48B122DC4D6E4005905C5 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = DF8AB7982E938B0B00E50BBF /* DevLog */; @@ -426,6 +548,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + DF3416452E45F67C00F9312B /* Build configuration list for PBXNativeTarget "DevLog_Unit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF34164D2E45F67C00F9312B /* Debug */, + DF34164E2E45F67C00F9312B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DFD48AFB2DC4D6E2005905C5 /* Build configuration list for PBXProject "DevLog" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/DevLog.xcodeproj/xcshareddata/xcschemes/DevLog.xcscheme b/DevLog.xcodeproj/xcshareddata/xcschemes/DevLog.xcscheme index b856df18..5b1f7f8b 100644 --- a/DevLog.xcodeproj/xcshareddata/xcschemes/DevLog.xcscheme +++ b/DevLog.xcodeproj/xcshareddata/xcschemes/DevLog.xcscheme @@ -29,6 +29,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + + + + + + + + + diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index a13ab372..d05e4386 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -193,9 +193,7 @@ final class PushNotificationService { } guard let snapshot else { return } - let unreadPushCount = snapshot.documents.filter { document in - !(document.data()[PushNotificationFieldKey.deletingAt.rawValue] is Timestamp) - }.count + let unreadPushCount = snapshot.documents.count subject.send(unreadPushCount) } @@ -304,8 +302,7 @@ private extension PushNotificationService { func makeResponse(from snapshot: QueryDocumentSnapshot) -> PushNotificationResponse? { let data = snapshot.data() - if data[PushNotificationFieldKey.deletingAt.rawValue] is Timestamp || - (data[PushNotificationFieldKey.isDeleted.rawValue] as? Bool) == true { + if (data[PushNotificationFieldKey.isDeleted.rawValue] as? Bool) == true { return nil } guard @@ -336,7 +333,6 @@ private extension PushNotificationService { case isRead case todoId case todoCategory - case deletingAt // 삭제 요청으로 앱의 로컬 데이터에서 deletion이 된 상태 case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 } } diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 709e925a..d1f40e74 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -125,9 +125,6 @@ final class WebPageService { private extension WebPageService { func makeResponse(from snapshot: QueryDocumentSnapshot) -> WebPageResponse? { let data = snapshot.data() - if data[WebPageFieldKey.deletingAt.rawValue] is Timestamp { - return nil - } guard (data[WebPageFieldKey.isDeleted.rawValue] as? Bool) != true, let title = data[WebPageFieldKey.title.rawValue] as? String, @@ -151,7 +148,6 @@ private extension WebPageService { case url case displayURL case imageURL - case deletingAt // 삭제 요청으로 앱의 로컬 데이터에서 deletion이 된 상태 case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 } } diff --git a/DevLog_Unit/PushNotification/DeletePushNotificationTests.swift b/DevLog_Unit/PushNotification/DeletePushNotificationTests.swift new file mode 100644 index 00000000..7e221562 --- /dev/null +++ b/DevLog_Unit/PushNotification/DeletePushNotificationTests.swift @@ -0,0 +1,116 @@ +// +// PushNotificationListViewModelTests.swift +// DevLog_Unit +// +// Created by opfic on 4/6/26. +// + +import Testing +import Foundation +@testable import DevLog + +@MainActor +struct DeletePushNotificationTests { + @Test("삭제하면 항목이 즉시 사라지고 되돌리기 토스트가 표시되며 삭제 유스케이스가 호출된다") + func 삭제하면_항목이_즉시_사라지고_되돌리기_토스트가_표시되며_삭제_유스케이스가_호출된다() async throws { + let fetchPushNotificationsUseCaseSpy = FetchPushNotificationsUseCaseSpy( + pushNotificationPage: PushNotificationPage( + items: [ + PushNotification( + id: "notification-1", + title: "title", + body: "body", + receivedAt: .now, + isRead: false, + todoId: "todo-1", + todoCategory: .system(.feature) + ) + ], + nextCursor: nil + ) + ) + let deletePushNotificationUseCaseSpy = DeletePushNotificationUseCaseSpy() + let undoDeletePushNotificationUseCaseSpy = UndoDeletePushNotificationUseCaseSpy() + let togglePushNotificationReadUseCaseSpy = TogglePushNotificationReadUseCaseSpy() + let fetchPushNotificationQueryUseCaseSpy = FetchPushNotificationQueryUseCaseSpy() + let updatePushNotificationQueryUseCaseSpy = UpdatePushNotificationQueryUseCaseSpy() + + let pushNotificationListViewModel = PushNotificationListViewModel( + fetchUseCase: fetchPushNotificationsUseCaseSpy, + deleteUseCase: deletePushNotificationUseCaseSpy, + undoDeleteUseCase: undoDeletePushNotificationUseCaseSpy, + toggleReadUseCase: togglePushNotificationReadUseCaseSpy, + fetchQueryUseCase: fetchPushNotificationQueryUseCaseSpy, + updateQueryUseCase: updatePushNotificationQueryUseCaseSpy + ) + + pushNotificationListViewModel.send(.fetchNotifications) + await waitUntil { + !pushNotificationListViewModel.state.notifications.isEmpty + } + + let pushNotificationItem = try #require(pushNotificationListViewModel.state.notifications.first) + + pushNotificationListViewModel.send(.deleteNotification(pushNotificationItem)) + + #expect(pushNotificationListViewModel.state.notifications.isEmpty) + #expect(pushNotificationListViewModel.state.showToast) + + await waitUntil { + deletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"] + } + + #expect(deletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"]) + } + + @Test("삭제를 되돌리면 되돌리기 유스케이스가 호출되고 다시 조회한다") + func 삭제를_되돌리면_되돌리기_유스케이스가_호출되고_다시_조회한다() async throws { + let fetchPushNotificationsUseCaseSpy = FetchPushNotificationsUseCaseSpy( + pushNotificationPage: PushNotificationPage( + items: [ + PushNotification( + id: "notification-1", + title: "title", + body: "body", + receivedAt: .now, + isRead: false, + todoId: "todo-1", + todoCategory: .system(.feature) + ) + ], + nextCursor: nil + ) + ) + let deletePushNotificationUseCaseSpy = DeletePushNotificationUseCaseSpy() + let undoDeletePushNotificationUseCaseSpy = UndoDeletePushNotificationUseCaseSpy() + let togglePushNotificationReadUseCaseSpy = TogglePushNotificationReadUseCaseSpy() + let fetchPushNotificationQueryUseCaseSpy = FetchPushNotificationQueryUseCaseSpy() + let updatePushNotificationQueryUseCaseSpy = UpdatePushNotificationQueryUseCaseSpy() + + let pushNotificationListViewModel = PushNotificationListViewModel( + fetchUseCase: fetchPushNotificationsUseCaseSpy, + deleteUseCase: deletePushNotificationUseCaseSpy, + undoDeleteUseCase: undoDeletePushNotificationUseCaseSpy, + toggleReadUseCase: togglePushNotificationReadUseCaseSpy, + fetchQueryUseCase: fetchPushNotificationQueryUseCaseSpy, + updateQueryUseCase: updatePushNotificationQueryUseCaseSpy + ) + + pushNotificationListViewModel.send(.fetchNotifications) + await waitUntil { + !pushNotificationListViewModel.state.notifications.isEmpty + } + + let pushNotificationItem = try #require(pushNotificationListViewModel.state.notifications.first) + + pushNotificationListViewModel.send(.deleteNotification(pushNotificationItem)) + pushNotificationListViewModel.send(.undoDelete) + + await waitUntil { + undoDeletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"] + } + + #expect(undoDeletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"]) + #expect(2 <= fetchPushNotificationsUseCaseSpy.executeCallCount) + } +} diff --git a/DevLog_Unit/PushNotification/Integration/DeletePushNotificationIntegrationTests.swift b/DevLog_Unit/PushNotification/Integration/DeletePushNotificationIntegrationTests.swift new file mode 100644 index 00000000..b8d70a6c --- /dev/null +++ b/DevLog_Unit/PushNotification/Integration/DeletePushNotificationIntegrationTests.swift @@ -0,0 +1,51 @@ +// +// DeletePushNotificationIntegrationTests.swift +// DevLog_Unit +// +// Created by opfic on 4/6/26. +// + +import Testing +import Foundation + +@Suite(.serialized) +struct DeletePushNotificationIntegrationTests { + @Test("푸시 알림 삭제를 되돌리면 목록에 보인다") + func 푸시_알림_삭제를_되돌리면_목록에_보인다() async throws { + let authSession = try await LocalFirebaseRESTSupport.shared.anonymousSignIn() + let notificationId = try await LocalFirebaseRESTSupport.shared.seedPushNotification( + userId: authSession.userId + ) + + try await LocalFirebaseRESTSupport.shared.requestPushNotificationDeletion( + notificationId: notificationId, + idToken: authSession.idToken + ) + + try await LocalFirebaseRESTSupport.shared.waitUntil { + let visibleNotificationIds = try await LocalFirebaseRESTSupport.shared.fetchPushNotificationIDs( + userId: authSession.userId + ) + return !visibleNotificationIds.contains(notificationId) + } + + try await LocalFirebaseRESTSupport.shared.undoPushNotificationDeletion( + notificationId: notificationId, + idToken: authSession.idToken + ) + + try await LocalFirebaseRESTSupport.shared.waitUntil { + let visibleNotificationIds = try await LocalFirebaseRESTSupport.shared.fetchPushNotificationIDs( + userId: authSession.userId + ) + return visibleNotificationIds.contains(notificationId) + } + + try await Task.sleep(for: .seconds(6)) + + let visibleNotificationIds = try await LocalFirebaseRESTSupport.shared.fetchPushNotificationIDs( + userId: authSession.userId + ) + #expect(visibleNotificationIds.contains(notificationId)) + } +} diff --git a/DevLog_Unit/Support/LocalFirebaseRESTSupport.swift b/DevLog_Unit/Support/LocalFirebaseRESTSupport.swift new file mode 100644 index 00000000..2910556a --- /dev/null +++ b/DevLog_Unit/Support/LocalFirebaseRESTSupport.swift @@ -0,0 +1,357 @@ +// +// LocalFirebaseRESTSupport.swift +// DevLog_Unit +// +// Created by opfic on 4/6/26. +// + +import Foundation + +final class LocalFirebaseRESTSupport { + struct AuthSession { + let userId: String + let idToken: String + } + + struct SeededWebPage { + let documentId: String + let urlString: String + } + + static let shared = LocalFirebaseRESTSupport() + + private let authBaseURL = URL(string: "http://127.0.0.1:9298")! + private let firestoreBaseURL = URL(string: "http://127.0.0.1:8280")! + private let functionsBaseURL = URL(string: "http://127.0.0.1:5201")! + + private init() { } + + func anonymousSignIn() async throws -> AuthSession { + let googleServiceInfo = try loadGoogleServiceInfo() + var request = URLRequest( + url: authBaseURL.appending( + path: "identitytoolkit.googleapis.com/v1/accounts:signUp", + directoryHint: .notDirectory + ).appending(queryItems: [ + URLQueryItem(name: "key", value: googleServiceInfo.apiKey) + ]) + ) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data( + withJSONObject: ["returnSecureToken": true] + ) + + let payload = try await sendJSON(request) + guard + let userId = payload["localId"] as? String, + let idToken = payload["idToken"] as? String + else { + throw RESTError.invalidResponse + } + + return AuthSession( + userId: userId, + idToken: idToken + ) + } + + func seedPushNotification( + userId: String, + notificationId: String = UUID().uuidString + ) async throws -> String { + let fields = [ + "title": stringValue("테스트 알림"), + "body": stringValue("undo 통합 테스트"), + "receivedAt": timestampValue(Date()), + "isRead": booleanValue(false), + "todoId": stringValue("todo-\(notificationId)"), + "todoCategory": stringValue("feature"), + "isDeleted": booleanValue(false) + ] + + try await upsertDocument( + documentPath: "users/\(userId)/notifications/\(notificationId)", + fields: fields + ) + + return notificationId + } + + func seedWebPage( + userId: String, + documentId: String = UUID().uuidString, + urlString: String = "https://example.com/\(UUID().uuidString)" + ) async throws -> SeededWebPage { + let fields = [ + "title": stringValue("Example"), + "url": stringValue(urlString), + "displayURL": stringValue(urlString), + "imageURL": stringValue(""), + "isDeleted": booleanValue(false) + ] + + try await upsertDocument( + documentPath: "users/\(userId)/webPages/\(documentId)", + fields: fields + ) + + return SeededWebPage( + documentId: documentId, + urlString: urlString + ) + } + + func requestPushNotificationDeletion( + notificationId: String, + idToken: String + ) async throws { + _ = try await callFunction( + name: "requestPushNotificationDeletion", + idToken: idToken, + data: ["notificationId": notificationId] + ) + } + + func undoPushNotificationDeletion( + notificationId: String, + idToken: String + ) async throws { + _ = try await callFunction( + name: "undoPushNotificationDeletion", + idToken: idToken, + data: ["notificationId": notificationId] + ) + } + + func requestWebPageDeletion( + urlString: String, + idToken: String + ) async throws { + _ = try await callFunction( + name: "requestWebPageDeletion", + idToken: idToken, + data: ["urlString": urlString] + ) + } + + func undoWebPageDeletion( + urlString: String, + idToken: String + ) async throws { + _ = try await callFunction( + name: "undoWebPageDeletion", + idToken: idToken, + data: ["urlString": urlString] + ) + } + + func fetchPushNotificationIDs(userId: String) async throws -> [String] { + let googleServiceInfo = try loadGoogleServiceInfo() + let url = firestoreBaseURL.appending( + path: "v1/projects/\(googleServiceInfo.projectId)/databases/(default)/documents/users/\(userId)/notifications", + directoryHint: .notDirectory + ) + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw RESTError.invalidResponse + } + guard 200 ..< 300 ~= httpResponse.statusCode else { + let body = String(data: data, encoding: .utf8) ?? "" + throw RESTError.unsuccessfulStatusCode(httpResponse.statusCode, body) + } + + let payload = try decodeJSON(data) + let documents = payload["documents"] as? [[String: Any]] ?? [] + + return documents.compactMap { document in + guard + let name = document["name"] as? String, + let fields = document["fields"] as? [String: [String: Any]], + boolValue(for: "isDeleted", in: fields) != true + else { + return nil + } + + return name.split(separator: "/").last.map(String.init) + } + } + + func fetchWebPageURLs(userId: String) async throws -> [String] { + let googleServiceInfo = try loadGoogleServiceInfo() + let url = firestoreBaseURL.appending( + path: "v1/projects/\(googleServiceInfo.projectId)/databases/(default)/documents/users/\(userId)/webPages", + directoryHint: .notDirectory + ) + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw RESTError.invalidResponse + } + guard 200 ..< 300 ~= httpResponse.statusCode else { + let body = String(data: data, encoding: .utf8) ?? "" + throw RESTError.unsuccessfulStatusCode(httpResponse.statusCode, body) + } + + let payload = try decodeJSON(data) + let documents = payload["documents"] as? [[String: Any]] ?? [] + + return documents.compactMap { document in + guard + let fields = document["fields"] as? [String: [String: Any]], + boolValue(for: "isDeleted", in: fields) != true + else { + return nil + } + + return fields["url"]?["stringValue"] as? String + } + } + + func waitUntil( + timeout: Duration = .seconds(3), + pollInterval: Duration = .milliseconds(100), + _ condition: @escaping () async throws -> Bool + ) async throws { + let continuousClock = ContinuousClock() + let deadline = continuousClock.now + timeout + + while continuousClock.now < deadline { + if try await condition() { + return + } + try await Task.sleep(for: pollInterval) + } + + throw RESTError.timedOut + } +} + +private extension LocalFirebaseRESTSupport { + struct GoogleServiceInfo { + let apiKey: String + let projectId: String + } + + enum RESTError: Error { + case invalidResponse + case unsuccessfulStatusCode(Int, String) + case missingConfiguration + case timedOut + } + + func loadGoogleServiceInfo() throws -> GoogleServiceInfo { + var fileURL = URL(fileURLWithPath: #filePath) + + while fileURL.lastPathComponent != "SwiftUI_DevLog" { + let nextURL = fileURL.deletingLastPathComponent() + if nextURL == fileURL { + throw RESTError.missingConfiguration + } + fileURL = nextURL + } + + let plistURL = fileURL + .appending(path: "DevLog") + .appending(path: "Resource") + .appending(path: "GoogleService-Info.plist") + let data = try Data(contentsOf: plistURL) + guard + let payload = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any], + let apiKey = payload["API_KEY"] as? String, + let projectId = payload["PROJECT_ID"] as? String + else { + throw RESTError.missingConfiguration + } + + return GoogleServiceInfo( + apiKey: apiKey, + projectId: projectId + ) + } + + func callFunction( + name: String, + idToken: String, + data: [String: Any] + ) async throws -> [String: Any] { + let googleServiceInfo = try loadGoogleServiceInfo() + var request = URLRequest( + url: functionsBaseURL.appending( + path: "\(googleServiceInfo.projectId)/asia-northeast3/\(name)", + directoryHint: .notDirectory + ) + ) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: ["data": data]) + return try await sendJSON(request) + } + + func upsertDocument( + documentPath: String, + fields: [String: [String: Any]] + ) async throws { + let googleServiceInfo = try loadGoogleServiceInfo() + let encodedPath = encode(documentPath) + var request = URLRequest( + url: firestoreBaseURL.appending( + path: "v1/projects/\(googleServiceInfo.projectId)/databases/(default)/documents/\(encodedPath)", + directoryHint: .notDirectory + ) + ) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["fields": fields]) + _ = try await sendJSON(request) + } + + func sendJSON(_ request: URLRequest) async throws -> [String: Any] { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw RESTError.invalidResponse + } + guard 200 ..< 300 ~= httpResponse.statusCode else { + let body = String(data: data, encoding: .utf8) ?? "" + throw RESTError.unsuccessfulStatusCode(httpResponse.statusCode, body) + } + return try decodeJSON(data) + } + + func decodeJSON(_ data: Data) throws -> [String: Any] { + guard let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw RESTError.invalidResponse + } + return payload + } + + func encode(_ path: String) -> String { + path.split(separator: "/").map { + String($0).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String($0) + }.joined(separator: "/") + } + + func stringValue(_ value: String) -> [String: Any] { + ["stringValue": value] + } + + func booleanValue(_ value: Bool) -> [String: Any] { + ["booleanValue": value] + } + + func timestampValue(_ value: Date) -> [String: Any] { + ["timestampValue": value.formatted(.iso8601)] + } + + func boolValue( + for field: String, + in fields: [String: [String: Any]]? + ) -> Bool? { + fields?[field]?["booleanValue"] as? Bool + } + +} diff --git a/DevLog_Unit/Support/TestSupport.swift b/DevLog_Unit/Support/TestSupport.swift new file mode 100644 index 00000000..8286d2eb --- /dev/null +++ b/DevLog_Unit/Support/TestSupport.swift @@ -0,0 +1,169 @@ +// +// TestSupport.swift +// DevLog_Unit +// +// Created by opfic on 4/6/26. +// + +import Testing +import Foundation +import Combine +@testable import DevLog + +@MainActor +func waitUntil( + timeout: Duration = .seconds(1), + pollInterval: Duration = .milliseconds(20), + _ condition: @escaping () -> Bool +) async { + let continuousClock = ContinuousClock() + let deadline = continuousClock.now + timeout + + while !condition() && continuousClock.now < deadline { + try? await Task.sleep(for: pollInterval) + } +} + +final class FetchPushNotificationsUseCaseSpy: FetchPushNotificationsUseCase { + var pushNotificationPage: PushNotificationPage + private(set) var executeCallCount = 0 + + init(pushNotificationPage: PushNotificationPage) { + self.pushNotificationPage = pushNotificationPage + } + + func execute( + _ query: PushNotificationQuery, + cursor: PushNotificationCursor? + ) async throws -> PushNotificationPage { + executeCallCount += 1 + return pushNotificationPage + } + + func observe( + _ query: PushNotificationQuery, + limit: Int + ) throws -> AnyPublisher { + Empty().eraseToAnyPublisher() + } +} + +final class DeletePushNotificationUseCaseSpy: DeletePushNotificationUseCase { + private(set) var calledNotificationIds: [String] = [] + + func execute(_ notificationID: String) async throws { + calledNotificationIds.append(notificationID) + } +} + +final class UndoDeletePushNotificationUseCaseSpy: UndoDeletePushNotificationUseCase { + private(set) var calledNotificationIds: [String] = [] + + func execute(_ notificationID: String) async throws { + calledNotificationIds.append(notificationID) + } +} + +final class TogglePushNotificationReadUseCaseSpy: TogglePushNotificationReadUseCase { + private(set) var calledTodoIds: [String] = [] + + func execute(_ todoId: String) async throws { + calledTodoIds.append(todoId) + } +} + +final class FetchPushNotificationQueryUseCaseSpy: FetchPushNotificationQueryUseCase { + var pushNotificationQuery = PushNotificationQuery.default + + func execute() -> PushNotificationQuery { + pushNotificationQuery + } +} + +final class UpdatePushNotificationQueryUseCaseSpy: UpdatePushNotificationQueryUseCase { + private(set) var queries: [PushNotificationQuery] = [] + + func execute(_ query: PushNotificationQuery) { + queries.append(query) + } +} + +final class FetchTodoCategoryPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCase { + var todoCategoryPreferences: [TodoCategoryPreference] = [] + + func execute() async throws -> [TodoCategoryPreference] { + todoCategoryPreferences + } +} + +final class UpdateTodoCategoryPreferencesUseCaseSpy: UpdateTodoCategoryPreferencesUseCase { + private(set) var updates: [[TodoCategoryPreference]] = [] + + func execute(_ preferences: [TodoCategoryPreference]) async throws { + updates.append(preferences) + } +} + +final class AddWebPageUseCaseSpy: AddWebPageUseCase { + private(set) var calledUrlStrings: [String] = [] + + func execute(_ urlString: String) async throws { + calledUrlStrings.append(urlString) + } +} + +final class DeleteWebPageUseCaseSpy: DeleteWebPageUseCase { + private(set) var calledUrlStrings: [String] = [] + + func execute(_ urlString: String) async throws { + calledUrlStrings.append(urlString) + } +} + +final class UndoDeleteWebPageUseCaseSpy: UndoDeleteWebPageUseCase { + private(set) var calledUrlStrings: [String] = [] + + func execute(_ urlString: String) async throws { + calledUrlStrings.append(urlString) + } +} + +final class UpsertTodoUseCaseSpy: UpsertTodoUseCase { + private(set) var todos: [Todo] = [] + + func execute(_ todo: Todo) async throws { + todos.append(todo) + } +} + +final class FetchTodosUseCaseSpy: FetchTodosUseCase { + var todoPage = TodoPage(items: [], nextCursor: nil) + private(set) var queries: [TodoQuery] = [] + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + return todoPage + } +} + +final class FetchWebPagesUseCaseSpy: FetchWebPagesUseCase { + var webPages: [WebPage] + private(set) var calledQueries: [String] = [] + + init(webPages: [WebPage]) { + self.webPages = webPages + } + + func execute(_ query: String) async throws -> [WebPage] { + calledQueries.append(query) + return webPages + } +} + +final class ObserveNetworkConnectivityUseCaseSpy: ObserveNetworkConnectivityUseCase { + let currentValueSubject = CurrentValueSubject(true) + + func observe() -> AnyPublisher { + currentValueSubject.eraseToAnyPublisher() + } +} diff --git a/DevLog_Unit/WebPage/DeleteWebPageTests.swift b/DevLog_Unit/WebPage/DeleteWebPageTests.swift new file mode 100644 index 00000000..7793a242 --- /dev/null +++ b/DevLog_Unit/WebPage/DeleteWebPageTests.swift @@ -0,0 +1,116 @@ +// +// HomeViewModelTests.swift +// DevLog_Unit +// +// Created by opfic on 4/6/26. +// + +import Testing +import Foundation +@testable import DevLog + +@MainActor +struct DeleteWebPageTests { + @Test("웹페이지를 삭제하면 항목이 즉시 사라지고 되돌리기 토스트가 표시되며 삭제 유스케이스가 호출된다") + func 웹페이지를_삭제하면_항목이_즉시_사라지고_되돌리기_토스트가_표시되며_삭제_유스케이스가_호출된다() async throws { + let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() + let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() + let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() + let deleteWebPageUseCaseSpy = DeleteWebPageUseCaseSpy() + let undoDeleteWebPageUseCaseSpy = UndoDeleteWebPageUseCaseSpy() + let upsertTodoUseCaseSpy = UpsertTodoUseCaseSpy() + let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() + let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( + webPages: [ + WebPage( + title: "OpenAI", + url: URL(string: "https://openai.com")!, + displayURL: URL(string: "https://openai.com")!, + imageURL: nil + ) + ] + ) + let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() + + let homeViewModel = HomeViewModel( + fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, + updatePreferencesUseCase: updateTodoCategoryPreferencesUseCaseSpy, + addWebPageUseCase: addWebPageUseCaseSpy, + deleteWebPageUseCase: deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCase: undoDeleteWebPageUseCaseSpy, + upsertTodoUseCase: upsertTodoUseCaseSpy, + fetchTodosUseCase: fetchTodosUseCaseSpy, + fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, + networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy + ) + + homeViewModel.send(.onAppear) + await waitUntil { + !homeViewModel.state.webPages.isEmpty + } + + let webPageItem = try #require(homeViewModel.state.webPages.first) + + homeViewModel.send(.deleteWebPage(webPageItem)) + + #expect(homeViewModel.state.webPages.isEmpty) + #expect(homeViewModel.state.showToast) + + await waitUntil { + deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + } + + #expect(deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) + } + + @Test("웹페이지 삭제를 되돌리면 되돌리기 유스케이스가 호출되고 목록을 다시 조회한다") + func 웹페이지_삭제를_되돌리면_되돌리기_유스케이스가_호출되고_목록을_다시_조회한다() async throws { + let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() + let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() + let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() + let deleteWebPageUseCaseSpy = DeleteWebPageUseCaseSpy() + let undoDeleteWebPageUseCaseSpy = UndoDeleteWebPageUseCaseSpy() + let upsertTodoUseCaseSpy = UpsertTodoUseCaseSpy() + let fetchTodosUseCaseSpy = FetchTodosUseCaseSpy() + let fetchWebPagesUseCaseSpy = FetchWebPagesUseCaseSpy( + webPages: [ + WebPage( + title: "OpenAI", + url: URL(string: "https://openai.com")!, + displayURL: URL(string: "https://openai.com")!, + imageURL: nil + ) + ] + ) + let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() + + let homeViewModel = HomeViewModel( + fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, + updatePreferencesUseCase: updateTodoCategoryPreferencesUseCaseSpy, + addWebPageUseCase: addWebPageUseCaseSpy, + deleteWebPageUseCase: deleteWebPageUseCaseSpy, + undoDeleteWebPageUseCase: undoDeleteWebPageUseCaseSpy, + upsertTodoUseCase: upsertTodoUseCaseSpy, + fetchTodosUseCase: fetchTodosUseCaseSpy, + fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, + networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy + ) + + homeViewModel.send(.onAppear) + await waitUntil { + !homeViewModel.state.webPages.isEmpty + } + + let webPageItem = try #require(homeViewModel.state.webPages.first) + + homeViewModel.send(.deleteWebPage(webPageItem)) + homeViewModel.send(.undoDeleteWebPage) + + await waitUntil { + undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + } + + #expect(undoDeleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"]) + #expect(2 <= fetchWebPagesUseCaseSpy.calledQueries.count) + } +} diff --git a/DevLog_Unit/WebPage/Integration/DeleteWebPageIntegrationTests.swift b/DevLog_Unit/WebPage/Integration/DeleteWebPageIntegrationTests.swift new file mode 100644 index 00000000..844c5f0f --- /dev/null +++ b/DevLog_Unit/WebPage/Integration/DeleteWebPageIntegrationTests.swift @@ -0,0 +1,51 @@ +// +// DeleteWebPageIntegrationTests.swift +// DevLog_Unit +// +// Created by opfic on 4/6/26. +// + +import Testing +import Foundation + +@Suite(.serialized) +struct DeleteWebPageIntegrationTests { + @Test("웹페이지 삭제를 되돌리면 목록에 보인다") + func 웹페이지_삭제를_되돌리면_목록에_보인다() async throws { + let authSession = try await LocalFirebaseRESTSupport.shared.anonymousSignIn() + let seededWebPage = try await LocalFirebaseRESTSupport.shared.seedWebPage( + userId: authSession.userId + ) + + try await LocalFirebaseRESTSupport.shared.requestWebPageDeletion( + urlString: seededWebPage.urlString, + idToken: authSession.idToken + ) + + try await LocalFirebaseRESTSupport.shared.waitUntil { + let visibleWebPageURLs = try await LocalFirebaseRESTSupport.shared.fetchWebPageURLs( + userId: authSession.userId + ) + return !visibleWebPageURLs.contains(seededWebPage.urlString) + } + + try await LocalFirebaseRESTSupport.shared.undoWebPageDeletion( + urlString: seededWebPage.urlString, + idToken: authSession.idToken + ) + + try await LocalFirebaseRESTSupport.shared.waitUntil { + let visibleWebPageURLs = try await LocalFirebaseRESTSupport.shared.fetchWebPageURLs( + userId: authSession.userId + ) + return visibleWebPageURLs.contains(seededWebPage.urlString) + } + + try await Task.sleep(for: .seconds(6)) + + let visibleWebPageURLs = try await LocalFirebaseRESTSupport.shared.fetchWebPageURLs( + userId: authSession.userId + ) + #expect(visibleWebPageURLs.contains(seededWebPage.urlString)) + } +} diff --git a/Firebase/firebase.json b/Firebase/firebase.json index 938146f8..09c33539 100644 --- a/Firebase/firebase.json +++ b/Firebase/firebase.json @@ -19,6 +19,9 @@ "indexes": "firestore.index.json" }, "emulators": { + "auth": { + "port": 9099 + }, "functions": { "port": 5001 }, diff --git a/Firebase/firebase.test.json b/Firebase/firebase.test.json new file mode 100644 index 00000000..4b51bef2 --- /dev/null +++ b/Firebase/firebase.test.json @@ -0,0 +1,34 @@ +{ + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ] + } + ], + "emulators": { + "auth": { + "port": 9298 + }, + "functions": { + "port": 5201 + }, + "firestore": { + "port": 8280 + }, + "ui": { + "enabled": true, + "port": 4100 + }, + "singleProjectMode": true, + "pubsub": { + "port": 8185 + } + } +} diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index afd085ad..a4919387 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -52,8 +52,7 @@ import { import { requestPushNotificationDeletion, - undoPushNotificationDeletion, - completePushNotificationDeletion + undoPushNotificationDeletion } from "./notification/deletion"; import { @@ -65,8 +64,7 @@ import { import { requestWebPageDeletion, - undoWebPageDeletion, - completeWebPageDeletion + undoWebPageDeletion } from "./webPage/deletion"; import { @@ -121,10 +119,8 @@ export { undoTodoDeletion, requestPushNotificationDeletion, undoPushNotificationDeletion, - completePushNotificationDeletion, cleanupSoftDeletedNotifications, requestWebPageDeletion, undoWebPageDeletion, - completeWebPageDeletion, cleanupSoftDeletedWebPages }; diff --git a/Firebase/functions/src/notification/deletion.ts b/Firebase/functions/src/notification/deletion.ts index 49406b80..25978e5a 100644 --- a/Firebase/functions/src/notification/deletion.ts +++ b/Firebase/functions/src/notification/deletion.ts @@ -1,18 +1,11 @@ import { onCall, HttpsError } from "firebase-functions/v2/https"; -import { onTaskDispatched } from "firebase-functions/v2/tasks"; -import { getFunctions } from "firebase-admin/functions"; +import { FieldValue } from "firebase-admin/firestore"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; -const DELETE_DELAY_SECONDS = 5; - -type NotificationDeletionPayload = { - userId: string; - notificationId: string; -}; export const requestPushNotificationDeletion = onCall({ cors: true, @@ -42,30 +35,23 @@ export const requestPushNotificationDeletion = onCall({ try { await notificationRef.set({ - // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다. - deletingAt: admin.firestore.FieldValue.serverTimestamp(), - isDeleted: false + deletingAt: FieldValue.delete(), + isDeleted: true }, {merge: true}); - - const queue = getFunctions().taskQueue( - `locations/${LOCATION}/functions/completePushNotificationDeletion` - ); - await queue.enqueue( - { userId, notificationId }, - {scheduleDelaySeconds: DELETE_DELAY_SECONDS} - ); } catch (error) { - const currentNotificationSnapshot = await notificationRef.get(); - if (currentNotificationSnapshot.exists && currentNotificationSnapshot.data()?.isDeleted !== true) { - await notificationRef.update({ - deletingAt: admin.firestore.FieldValue.delete() - }); + try { + const currentNotificationSnapshot = await notificationRef.get(); + if (currentNotificationSnapshot.exists && currentNotificationSnapshot.data()?.isDeleted === true) { + await notificationRef.update({ + deletingAt: FieldValue.delete(), + isDeleted: false + }); + } + } catch (cleanupError) { + logger.error("푸시 알림 삭제 요청 cleanup 실패", toError(cleanupError), { userId, notificationId }); } - logger.error("푸시 알림 삭제 요청 실패", toError(error), { - userId, - notificationId - }); + logger.error("푸시 알림 삭제 요청 실패", toError(error), { userId, notificationId }); throw new HttpsError("internal", "푸시 알림 삭제 요청에 실패했습니다."); } @@ -95,9 +81,9 @@ export const undoPushNotificationDeletion = onCall({ try { const notificationSnapshot = await notificationRef.get(); - if (notificationSnapshot.exists && notificationSnapshot.data()?.isDeleted !== true) { + if (notificationSnapshot.exists && notificationSnapshot.data()?.isDeleted === true) { await notificationRef.update({ - deletingAt: admin.firestore.FieldValue.delete(), + deletingAt: FieldValue.delete(), isDeleted: false }); } @@ -112,61 +98,3 @@ export const undoPushNotificationDeletion = onCall({ return {success: true}; } ); - -export const completePushNotificationDeletion = onTaskDispatched({ - maxInstances: 5, - region: LOCATION, - retryConfig: { maxAttempts: 3, minBackoffSeconds: 5}, - rateLimits: { maxDispatchesPerSecond: 5 }, - }, - async (request) => { - const payload = parseDeletionPayload(request.data); - if (!payload) { - logger.warn("유효하지 않은 푸시 알림 삭제 payload", request.data); - return; - } - - const { userId, notificationId } = payload; - - const notificationRef = admin.firestore().doc(FirestorePath.notification(userId, notificationId)); - - try { - const notificationSnapshot = await notificationRef.get(); - const deletingAt = notificationSnapshot.data()?.deletingAt; - const isDeleted = notificationSnapshot.data()?.isDeleted === true; - - if (!notificationSnapshot.exists || !deletingAt || isDeleted) { - return; - } - - await notificationRef.set({ - deletingAt: admin.firestore.FieldValue.delete(), - isDeleted: true - }, { merge: true }); - } catch (error) { - logger.error("푸시 알림 최종 삭제 실패", toError(error), { - userId, - notificationId - }); - throw error; - } - } -); - -function parseDeletionPayload(data: unknown): NotificationDeletionPayload | null { - const userId = typeof (data as NotificationDeletionPayload | undefined)?.userId === "string" ? - (data as NotificationDeletionPayload).userId.trim() : - ""; - const notificationId = typeof (data as NotificationDeletionPayload | undefined)?.notificationId === "string" ? - (data as NotificationDeletionPayload).notificationId.trim() : - ""; - - if (!userId || !notificationId) { - return null; - } - - return { - userId, - notificationId - }; -} diff --git a/Firebase/functions/src/webPage/deletion.ts b/Firebase/functions/src/webPage/deletion.ts index 65c4c338..4e7999eb 100644 --- a/Firebase/functions/src/webPage/deletion.ts +++ b/Firebase/functions/src/webPage/deletion.ts @@ -1,18 +1,11 @@ import { onCall, HttpsError } from "firebase-functions/v2/https"; -import { onTaskDispatched } from "firebase-functions/v2/tasks"; -import { getFunctions } from "firebase-admin/functions"; +import { FieldValue } from "firebase-admin/firestore"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; import { toError } from "../common/error"; import { FirestorePath } from "../common/firestorePath"; const LOCATION = "asia-northeast3"; -const DELETE_DELAY_SECONDS = 5; - -type WebPageDeletionPayload = { - userId: string; - urlString: string; -}; export const requestWebPageDeletion = onCall({ cors: true, @@ -47,23 +40,22 @@ export const requestWebPageDeletion = onCall({ try { await webPageRef.set({ - // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 soft delete 되기 전 상태를 의미한다. - deletingAt: admin.firestore.FieldValue.serverTimestamp(), - isDeleted: false + deletingAt: FieldValue.delete(), + isDeleted: true }, { merge: true }); - - const queue = getFunctions().taskQueue( - `locations/${LOCATION}/functions/completeWebPageDeletion` - ); - await queue.enqueue( - { userId, urlString }, - { scheduleDelaySeconds: DELETE_DELAY_SECONDS } - ); } catch (error) { - const currentWebPageSnapshot = await webPageRef.get(); - if (currentWebPageSnapshot.exists && currentWebPageSnapshot.data()?.isDeleted !== true) { - await webPageRef.update({ - deletingAt: admin.firestore.FieldValue.delete() + try { + const currentWebPageSnapshot = await webPageRef.get(); + if (currentWebPageSnapshot.exists && currentWebPageSnapshot.data()?.isDeleted === true) { + await webPageRef.update({ + deletingAt: FieldValue.delete(), + isDeleted: false + }); + } + } catch (cleanupError) { + logger.error("웹페이지 삭제 요청 cleanup 실패", toError(cleanupError), { + userId, + urlString }); } @@ -110,9 +102,9 @@ export const undoWebPageDeletion = onCall({ try { const currentWebPageSnapshot = await webPageRef.get(); - if (currentWebPageSnapshot.exists && currentWebPageSnapshot.data()?.isDeleted !== true) { + if (currentWebPageSnapshot.exists && currentWebPageSnapshot.data()?.isDeleted === true) { await webPageRef.update({ - deletingAt: admin.firestore.FieldValue.delete(), + deletingAt: FieldValue.delete(), isDeleted: false }); } @@ -127,69 +119,3 @@ export const undoWebPageDeletion = onCall({ return { success: true }; } ); - -export const completeWebPageDeletion = onTaskDispatched({ - maxInstances: 5, - region: LOCATION, - retryConfig: { maxAttempts: 3, minBackoffSeconds: 5 }, - rateLimits: { maxDispatchesPerSecond: 5 }, - }, - async (request) => { - const payload = parseDeletionPayload(request.data); - if (!payload) { - logger.warn("유효하지 않은 웹페이지 삭제 payload", request.data); - return; - } - - const { userId, urlString } = payload; - const webPageSnapshot = await admin.firestore() - .collection(FirestorePath.webPages(userId)) - .where("url", "==", urlString) - .limit(1) - .get(); - if (webPageSnapshot.empty) { - return; - } - - const webPageRef = webPageSnapshot.docs[0].ref; - - try { - const currentWebPageSnapshot = await webPageRef.get(); - const deletingAt = currentWebPageSnapshot.data()?.deletingAt; - const isDeleted = currentWebPageSnapshot.data()?.isDeleted === true; - - if (!currentWebPageSnapshot.exists || !deletingAt || isDeleted) { - return; - } - - await webPageRef.set({ - deletingAt: admin.firestore.FieldValue.delete(), - isDeleted: true - }, { merge: true }); - } catch (error) { - logger.error("웹페이지 최종 soft delete 실패", toError(error), { - userId, - urlString - }); - throw error; - } - } -); - -function parseDeletionPayload(data: unknown): WebPageDeletionPayload | null { - const userId = typeof (data as WebPageDeletionPayload | undefined)?.userId === "string" ? - (data as WebPageDeletionPayload).userId.trim() : - ""; - const urlString = typeof (data as WebPageDeletionPayload | undefined)?.urlString === "string" ? - (data as WebPageDeletionPayload).urlString.trim() : - ""; - - if (!userId || !urlString) { - return null; - } - - return { - userId, - urlString - }; -}