diff --git a/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift index 6fd049c9..6465d958 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift @@ -146,7 +146,8 @@ public struct SettingsRepositoryImpl: SettingsRepository, @unchecked Sendable { entitlement: cachedEntitlement(), productID: defaults.string(forKey: .cachedSubscriptionProductID), renewsAt: defaults.date(forKey: .cachedSubscriptionRenewsAt), - willAutoRenew: defaults.bool(forKey: .cachedSubscriptionWillAutoRenew) + willAutoRenew: defaults.bool(forKey: .cachedSubscriptionWillAutoRenew), + renewalProductID: defaults.string(forKey: .cachedSubscriptionRenewalProductID) ) } @@ -163,6 +164,11 @@ public struct SettingsRepositoryImpl: SettingsRepository, @unchecked Sendable { defaults.removeObject(forKey: .cachedSubscriptionRenewsAt) } defaults.set(status.willAutoRenew, forKey: .cachedSubscriptionWillAutoRenew) + if let renewalProductID = status.renewalProductID { + defaults.set(renewalProductID, forKey: .cachedSubscriptionRenewalProductID) + } else { + defaults.removeObject(forKey: .cachedSubscriptionRenewalProductID) + } } // MARK: - Security diff --git a/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift b/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift index a59266f3..2120f252 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift @@ -36,6 +36,7 @@ enum UserDefaultsKey: String { case cachedSubscriptionProductID case cachedSubscriptionRenewsAt case cachedSubscriptionWillAutoRenew + case cachedSubscriptionRenewalProductID } extension UserDefaults { diff --git a/Projects/DVData/Sources/ServiceImpl/Purchase/PurchaseServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Purchase/PurchaseServiceImpl.swift index b8b11d23..222055ce 100644 --- a/Projects/DVData/Sources/ServiceImpl/Purchase/PurchaseServiceImpl.swift +++ b/Projects/DVData/Sources/ServiceImpl/Purchase/PurchaseServiceImpl.swift @@ -135,7 +135,8 @@ public struct PurchaseServiceImpl: PurchaseService { productID: transaction.productID, renewsAt: transaction.expirationDate, willAutoRenew: renewal.willAutoRenew, - renewalProductID: renewal.autoRenewPreference + // 자동 갱신이 꺼져 있으면 다음 갱신 자체가 없으므로 예약 상품도 없다(있어도 "변경 예약"이 아니라 만료). + renewalProductID: renewal.willAutoRenew ? renewal.autoRenewPreference : nil ) } diff --git a/Projects/DVData/Tests/RepositoryImpl/Settings/SettingsRepositoryImplSubscriptionCacheTests.swift b/Projects/DVData/Tests/RepositoryImpl/Settings/SettingsRepositoryImplSubscriptionCacheTests.swift new file mode 100644 index 00000000..0103d451 --- /dev/null +++ b/Projects/DVData/Tests/RepositoryImpl/Settings/SettingsRepositoryImplSubscriptionCacheTests.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import Testing + +import DVDomain +@testable import DVData + +@Suite("SettingsRepositoryImpl - 구독 상태 캐시") +struct SettingsRepositoryImplSubscriptionCacheTests { + + private func makeSUT() -> SettingsRepositoryImpl { + let defaults = UserDefaults(suiteName: "SettingsRepositoryImplTests.\(UUID().uuidString)")! + return SettingsRepositoryImpl(defaults: defaults) + } + + @Test("예약된 플랜 변경(renewalProductID)이 캐시 왕복에서 보존된다") + func renewalProductIDSurvivesRoundTrip() { + let sut = makeSUT() + sut.setCachedEntitlement(.pro) + sut.setCachedSubscriptionStatus( + SubscriptionStatus( + entitlement: .pro, + productID: "pro.monthly", + renewsAt: Date(timeIntervalSince1970: 1_700_000_000), + willAutoRenew: true, + renewalProductID: "pro.yearly" + ) + ) + + let restored = sut.cachedSubscriptionStatus() + + #expect(restored.renewalProductID == "pro.yearly") + #expect(restored.hasPendingPlanChange) + } + + @Test("예약이 없으면 renewalProductID는 nil로 복원된다") + func renewalProductIDClearedWhenAbsent() { + let sut = makeSUT() + sut.setCachedEntitlement(.pro) + // 예약이 있던 상태를 + sut.setCachedSubscriptionStatus( + SubscriptionStatus(entitlement: .pro, productID: "pro.monthly", willAutoRenew: true, renewalProductID: "pro.yearly") + ) + // 예약 없는 상태로 덮어쓰면 캐시에서도 지워져야 한다 + sut.setCachedSubscriptionStatus( + SubscriptionStatus(entitlement: .pro, productID: "pro.monthly", willAutoRenew: true, renewalProductID: nil) + ) + + #expect(sut.cachedSubscriptionStatus().renewalProductID == nil) + } +} diff --git a/Projects/DVDomain/Sources/Entity/SubscriptionStatus.swift b/Projects/DVDomain/Sources/Entity/SubscriptionStatus.swift index 4aaeb8f6..a0c9e39e 100644 --- a/Projects/DVDomain/Sources/Entity/SubscriptionStatus.swift +++ b/Projects/DVDomain/Sources/Entity/SubscriptionStatus.swift @@ -41,7 +41,8 @@ public struct SubscriptionStatus: Equatable, Sendable { /// 다음 갱신에 다른 플랜으로 바뀌도록 예약돼 있는지. public var hasPendingPlanChange: Bool { - guard let productID, let renewalProductID else { return false } + // 자동 갱신이 꺼져 있으면 갱신 자체가 없다 — autoRenewPreference가 남아 있어도 "변경 예약"이 아니라 만료다. + guard willAutoRenew, let productID, let renewalProductID else { return false } return renewalProductID != productID } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Settings/ICloudSettingsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Settings/ICloudSettingsUseCaseImpl.swift index fb7bfba3..d0aee761 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Settings/ICloudSettingsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Settings/ICloudSettingsUseCaseImpl.swift @@ -31,8 +31,15 @@ public struct ICloudSettingsUseCaseImpl: ICloudSettingsUseCase { if enabled, !entitlementUseCase.canEnableICloudSync() { throw EntitlementError.requiresPro } - try await iCloudService.configureStorage(iCloudSyncEnabled: enabled) - repository.setICloudSyncEnabled(enabled) + guard enabled else { + // 끄기는 fail-safe: 전환이 실패해도 플래그를 먼저 false로 확정한다(실패가 "free인데 동기화"로 남지 않게). + repository.setICloudSyncEnabled(false) + try await iCloudService.configureStorage(iCloudSyncEnabled: false) + return + } + // 켜기는 fail-secure: 저장소 전환이 성공해야 플래그를 올린다(실패를 켜짐으로 오인 금지). + try await iCloudService.configureStorage(iCloudSyncEnabled: true) + repository.setICloudSyncEnabled(true) } public func accountStatus() async -> ICloudAccountStatus { diff --git a/Projects/DVDomain/Tests/Core/Entity/SubscriptionStatusTests.swift b/Projects/DVDomain/Tests/Core/Entity/SubscriptionStatusTests.swift index 520b4425..8c7ca5e5 100644 --- a/Projects/DVDomain/Tests/Core/Entity/SubscriptionStatusTests.swift +++ b/Projects/DVDomain/Tests/Core/Entity/SubscriptionStatusTests.swift @@ -40,6 +40,17 @@ struct SubscriptionStatusTests { #expect(status.hasPendingPlanChange == false) } + @Test("자동 갱신이 꺼져 있으면 다음 갱신 상품이 달라도 변경 예약이 아니다(만료)") + func notPendingWhenAutoRenewOff() { + let status = SubscriptionStatus( + entitlement: .pro, + productID: "pro.monthly", + willAutoRenew: false, + renewalProductID: "pro.yearly" + ) + #expect(status.hasPendingPlanChange == false) + } + @Test("무료(현재 상품 없음)면 변경 예약이 아니다") func notPendingWhenFree() { #expect(SubscriptionStatus.free.hasPendingPlanChange == false) diff --git a/Projects/DVDomain/Tests/Core/UseCase/Settings/ICloudSettingsUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Settings/ICloudSettingsUseCaseImplTests.swift index 8018dcb8..c393f7d6 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Settings/ICloudSettingsUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Settings/ICloudSettingsUseCaseImplTests.swift @@ -56,6 +56,26 @@ struct ICloudSettingsUseCaseImplTests { #expect(repository.isICloudSyncEnabled() == false) } + // 끄기는 fail-safe여야 한다: 저장소 전환이 실패해도 free가 계속 동기화되면 안 된다. + @Test("동기화 끄기: 저장소 전환이 실패해도 플래그는 false로 확정된다") + func disableForcesFlagOffEvenWhenStorageSwitchFails() async { + let repository = FakeSettingsRepository() + repository.setICloudSyncEnabled(true) // 이미 켜진 상태에서 다운그레이드 + let sut = ICloudSettingsUseCaseImpl( + repository: repository, + iCloudService: StubICloudService( + status: .available, + configurationFails: true + ), + entitlementUseCase: StubEntitlementUseCase() + ) + + await #expect(throws: StubICloudService.Error.configurationFailed) { + try await sut.setEnabled(false) + } + #expect(repository.isICloudSyncEnabled() == false) // 전환 실패해도 플래그는 꺼짐 + } + @Test("iCloud 원격 변경 스트림을 Service에 위임한다") func remoteChangeStreamUsesService() async { let sut = ICloudSettingsUseCaseImpl( diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index e8832d72..6278e7cc 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -4319,6 +4319,17 @@ } } }, + "Changes to %@ at your next renewal" : { + "comment" : "Subscription screen: a plan change is scheduled for the next renewal but the renewal date is unavailable. The argument is the upcoming plan name.", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 갱신부터 %@ 적용 예정" + } + } + } + }, "Changes to %@ on %@" : { "comment" : "Shown on the subscription screen when the plan will switch to a different one at the next renewal. First argument is the upcoming plan name, second is the renewal date.", "localizations" : { diff --git a/Projects/DVPresentation/Sources/Dependencies/App/AppLaunchClient.swift b/Projects/DVPresentation/Sources/Dependencies/App/AppLaunchClient.swift index 8d75d6cd..63f893c6 100644 --- a/Projects/DVPresentation/Sources/Dependencies/App/AppLaunchClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/App/AppLaunchClient.swift @@ -20,6 +20,8 @@ public struct AppLaunchClient: Sendable { } /// 마지막 CloudKit 원격 변경 감지 시각을 저장한다. public var setICloudLastUpdateDetectedAt: @Sendable (Date) -> Void + /// 등급이 free로 내려갔을 때 호출한다. iCloud 동기화가 켜져 있으면 끈다(로컬 데이터는 유지, 미러링만 중단). + public var disableICloudSyncForDowngrade: @Sendable () async -> Void } extension AppLaunchClient: TestDependencyKey { @@ -31,7 +33,8 @@ extension AppLaunchClient: TestDependencyKey { requestNotificationAuthorization: { true }, syncExpiryNotifications: {}, iCloudRemoteChangeStream: { AsyncStream { $0.finish() } }, - setICloudLastUpdateDetectedAt: { _ in } + setICloudLastUpdateDetectedAt: { _ in }, + disableICloudSyncForDowngrade: {} ) } diff --git a/Projects/DVPresentation/Sources/Features/AppFeature.swift b/Projects/DVPresentation/Sources/Features/AppFeature.swift index fcc0c5b3..175c1435 100644 --- a/Projects/DVPresentation/Sources/Features/AppFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AppFeature.swift @@ -54,6 +54,7 @@ public struct AppFeature { case iCloudRemoteChangeDetected case iCloudRemoteChangeHandled case entitlementChanged + case entitlementSettledAtLaunch(isFree: Bool) // MARK: - Child @@ -152,7 +153,18 @@ public struct AppFeature { case .entitlementChanged: // 등급이 바뀌면 만료 알림 시점 한도가 달라진다. 예약은 저장된 선택이 아니라 등급을 함께 보고 계산되므로, 다시 예약해야 강등 뒤에도 무료 한도가 지켜진다. - return .run { _ in await appLaunchClient.syncExpiryNotifications() } + return .merge( + .run { _ in await appLaunchClient.syncExpiryNotifications() }, + // iCloud 동기화는 Pro 전용이라, free로 내려가면 자동으로 끈다(로컬 데이터는 유지, 미러링만 중단). + entitlementClient.current() == .free + ? .run { _ in await appLaunchClient.disableICloudSyncForDowngrade() } + : .none + ) + + case let .entitlementSettledAtLaunch(isFree): + // 앱 꺼진 사이 만료돼 free로 시작하면 여기서 동기화를 끈다(bootstrap이 stale .pro로 켜뒀어도 정리). Pro면 무시. + guard isFree else { return .none } + return .run { _ in await appLaunchClient.disableICloudSyncForDowngrade() } case .iCloudRemoteChangeHandled: guard state.main != nil else { return .none } @@ -244,12 +256,14 @@ private extension AppFeature { func entitlementWatchEffect() -> Effect { .run { send in var isFirst = true - for await _ in entitlementClient.stream() { - guard !isFirst else { + for await entitlement in entitlementClient.stream() { + if isFirst { isFirst = false - continue + // 첫 방출은 알림 재동기화(.task가 이미 함)는 건너뛰되, 이미 free면 동기화 종료는 처리한다. + await send(.entitlementSettledAtLaunch(isFree: entitlement == .free)) + } else { + await send(.entitlementChanged) } - await send(.entitlementChanged) } } .cancellable(id: CancelID.entitlementWatch, cancelInFlight: true) diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift index 8843a42e..87c7a304 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift @@ -26,6 +26,8 @@ public struct OnboardingFeature { public var step: Step = .welcome public var isEnablingSync = false @Presents var alert: AlertState? + /// free 사용자가 Enable Sync를 누르면 여기로 업그레이드 시트를 띄운다. 구매를 마치면 자동으로 동기화를 켠다. + @Presents var paywall: DevaultProPaywallFeature.State? public init(step: Step = .welcome) { self.step = step @@ -58,10 +60,12 @@ public struct OnboardingFeature { case touchIDAuthFailed(UserAuthenticationError) case iCloudSyncStatusResponse(ICloudAccountStatus) case enableSyncCompleted + case entitlementRechecked // MARK: - Child case alert(PresentationAction) + case paywall(PresentationAction) // MARK: - Delegate @@ -81,6 +85,8 @@ public struct OnboardingFeature { // MARK: - Dependencies @Dependency(\.onboardingClient) var onboardingClient + @Dependency(\.entitlementClient) var entitlementClient + @Dependency(\.purchaseClient) var purchaseClient @Dependency(\.continuousClock) var clock // MARK: - Init @@ -122,15 +128,25 @@ public struct OnboardingFeature { case .didTapEnableSync: state.isEnablingSync = true + // 이미 Pro면(다른 기기 구독 포함 — 대개 실행 시 이미 반영됨) 페이월 없이 바로 켠다. + if entitlementClient.canEnableICloudSync() { + return enableICloudSyncEffect() + } + // 아직 free로 보이면 스토어에 한 번 더 물어 다른 기기 구독을 인식한 뒤 판정한다. return .run { send in - do { - let status = try await onboardingClient.enableICloudSync() - await send(.iCloudSyncStatusResponse(status)) - } catch { - await send(.iCloudSyncStatusResponse(.configurationUnavailable)) - } + await purchaseClient.refreshEntitlement() + await send(.entitlementRechecked) } + case .entitlementRechecked: + // 재조회 후에도 Pro가 아니면 진짜 free — 페이월을 띄운다. Pro면 그대로 켠다. + guard entitlementClient.canEnableICloudSync() else { + state.isEnablingSync = false + state.paywall = DevaultProPaywallFeature.State() + return .none + } + return enableICloudSyncEffect() + case .iCloudSyncStatusResponse(let status): guard status == .available else { state.isEnablingSync = false @@ -149,6 +165,16 @@ public struct OnboardingFeature { await send(.delegate(.completed)) } + case .paywall(.presented(.delegate(.didFinish))): + // 구매/복원 성공 → 이제 Pro. 페이월을 닫고 자동으로 동기화를 켠다. + state.paywall = nil + guard entitlementClient.canEnableICloudSync() else { return .none } + state.isEnablingSync = true + return enableICloudSyncEffect() + + case .paywall: + return .none + case .alert(.presented(.retry)): return .send(.didTapEnableSync) @@ -167,6 +193,9 @@ public struct OnboardingFeature { } } .ifLet(\.$alert, action: \.alert) + .ifLet(\.$paywall, action: \.paywall) { + DevaultProPaywallFeature() + } } } @@ -187,6 +216,18 @@ private extension OnboardingFeature { ) } + /// iCloud 계정 상태를 확인하고 동기화를 켠다. Pro가 확정된 뒤에만 호출한다(계정 미가용은 응답에서 알럿으로 처리). + func enableICloudSyncEffect() -> Effect { + .run { send in + do { + let status = try await onboardingClient.enableICloudSync() + await send(.iCloudSyncStatusResponse(status)) + } catch { + await send(.iCloudSyncStatusResponse(.configurationUnavailable)) + } + } + } + func continueWithoutICloudEffect() -> Effect { .run { send in await onboardingClient.continueWithoutICloud() diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift index 6eba6402..dddf6fae 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift @@ -25,6 +25,9 @@ public struct OnboardingView: View { content .dvScreenBackground() .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.paywall, action: \.paywall)) { paywallStore in + DevaultProPaywallView(store: paywallStore) + } } } diff --git a/Projects/DVPresentation/Sources/Features/Settings/DevaultPro/DevaultProSettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/DevaultPro/DevaultProSettingsView.swift index 9bcd1143..0ce277e3 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/DevaultPro/DevaultProSettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/DevaultPro/DevaultProSettingsView.swift @@ -137,12 +137,16 @@ extension DevaultProSettingsView { } private var renewalDescription: String? { - guard let renewsAt = store.subscriptionStatus.renewsAt else { return nil } - let dateText = renewsAt.formatted(date: .abbreviated, time: .omitted) + let dateText = store.subscriptionStatus.renewsAt?.formatted(date: .abbreviated, time: .omitted) // 변경 예약이 있으면(다음 갱신부터 다른 플랜) 그걸 우선 안내한다 — "현재는 1개월인데 3개월로 바꿨다"는 혼란을 없앤다. + // 갱신일이 없어도(드묾) 예약 사실 자체는 알려, 예약 안내가 날짜 유무에 가려지지 않게 한다. if let renewalPlanName = store.renewalPlanName { + guard let dateText else { + return String(format: String.module("Changes to %@ at your next renewal"), renewalPlanName) + } return String(format: String.module("Changes to %@ on %@"), renewalPlanName, dateText) } + guard let dateText else { return nil } return store.subscriptionStatus.willAutoRenew ? String(format: String.module("Renews on %@"), dateText) : String(format: String.module("Expires on %@"), dateText) diff --git a/Projects/DVPresentation/Tests/AppFeatureTests.swift b/Projects/DVPresentation/Tests/AppFeatureTests.swift index 018c22a0..d0701534 100644 --- a/Projects/DVPresentation/Tests/AppFeatureTests.swift +++ b/Projects/DVPresentation/Tests/AppFeatureTests.swift @@ -272,6 +272,72 @@ struct AppFeatureTests { #expect(savedDate.value == date) } + // iCloud 동기화는 Pro 전용이라, 등급이 free로 내려가면 자동으로 꺼야 무료 사용자가 계속 동기화되지 않는다. + @Test("등급이 free로 내려가면 iCloud 동기화를 자동으로 끄고 만료 알림을 다시 동기화한다") + func entitlementDowngradeToFreeDisablesICloudSync() async { + let synced = LockIsolated(false) + let disabled = LockIsolated(false) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.entitlementClient.current = { .free } + $0.appLaunchClient.syncExpiryNotifications = { synced.setValue(true) } + $0.appLaunchClient.disableICloudSyncForDowngrade = { disabled.setValue(true) } + } + + await store.send(.entitlementChanged) + await store.finish() + + #expect(synced.value) + #expect(disabled.value) + } + + // 업그레이드나 Pro→Pro 플랜 변경에서는 동기화를 끄지 않는다. + @Test("등급이 pro면 만료 알림만 다시 동기화하고 iCloud 동기화는 끄지 않는다") + func entitlementChangeToProDoesNotDisableICloudSync() async { + let synced = LockIsolated(false) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.entitlementClient.current = { .pro } + $0.appLaunchClient.syncExpiryNotifications = { synced.setValue(true) } + // disableICloudSyncForDowngrade를 오버라이드하지 않는다 — 호출되면 + // @DependencyClient의 unimplemented 클로저가 테스트를 실패시킨다. + } + + await store.send(.entitlementChanged) + await store.finish() + + #expect(synced.value) + } + + // 앱이 꺼진 사이 만료돼 free로 확정된 채 시작하면, 스트림 첫 방출에서도 동기화를 강제 종료해야 한다. + @Test("첫 방출이 free면 iCloud 동기화를 강제 종료한다") + func settledAtLaunchFreeDisablesICloudSync() async { + let disabled = LockIsolated(false) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.appLaunchClient.disableICloudSyncForDowngrade = { disabled.setValue(true) } + } + + await store.send(.entitlementSettledAtLaunch(isFree: true)) + await store.finish() + + #expect(disabled.value) + } + + @Test("첫 방출이 pro면 iCloud 동기화를 건드리지 않는다") + func settledAtLaunchProDoesNotDisableICloudSync() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + // disableICloudSyncForDowngrade를 오버라이드하지 않는다 — 호출되면 미구현 클로저가 실패시킨다. + + await store.send(.entitlementSettledAtLaunch(isFree: false)) + await store.finish() + } + @Test("잠금 해제하면 새 앱 비활성 감시를 시작한다") func unlockStartsNewInactivityMonitoring() async { var initial = AppFeature.State() diff --git a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift index 907aec94..37c2af3a 100644 --- a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift +++ b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift @@ -1,5 +1,7 @@ // Copyright © 2026 Devault. All rights reserved +import ComposableArchitecture +import Foundation import Testing @testable import DVPresentation @@ -29,7 +31,12 @@ struct HelpMenuLinkTests { @Test("Send Feedback은 팀 이메일 mailto로 연결되고 subject·body 틀을 담는다") func sendFeedbackURL() throws { - let url = HelpMenuLink.sendFeedback.url + // 피드백 본문의 Plan 줄이 entitlementClient.current()를 읽으므로 테스트가 등급을 명시한다. + let url = withDependencies { + $0.entitlementClient.current = { .free } + } operation: { + HelpMenuLink.sendFeedback.url + } let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) #expect(url.scheme == "mailto") @@ -42,6 +49,19 @@ struct HelpMenuLinkTests { #expect(body.contains("App Version:")) #expect(body.contains("macOS:")) #expect(body.contains("Mac:")) - #expect(body.contains("Plan:")) + // 라벨만이 아니라 주입한 등급 값까지 검증한다 — current()를 실제로 읽는지 확인. + #expect(body.contains("Plan: Free")) + + // 등급이 바뀌면 Plan 값도 달라져야 한다. + let proURL = withDependencies { + $0.entitlementClient.current = { .pro } + } operation: { + HelpMenuLink.sendFeedback.url + } + let proBody = try #require( + URLComponents(url: proURL, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "body" }?.value + ) + #expect(proBody.contains("Plan: Pro")) } } diff --git a/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift b/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift index eb931168..57d812e8 100644 --- a/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Onboarding/OnboardingFeatureTests.swift @@ -145,6 +145,77 @@ struct OnboardingFeatureTests { #expect(opened.value == true) } + @Test("didTapEnableSync는 free면 재조회 후에도 Pro가 아니라 페이월을 띄운다") + func enableSyncPresentsPaywallWhenFree() async { + let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { + OnboardingFeature() + } withDependencies: { + $0.entitlementClient.canEnableICloudSync = { false } + $0.purchaseClient.refreshEntitlement = { } + } + + await store.send(.didTapEnableSync) { + $0.isEnablingSync = true + } + await store.receive(.entitlementRechecked) { + $0.isEnablingSync = false + $0.paywall = DevaultProPaywallFeature.State() + } + } + + // 다른 기기에서 이미 구독한 사용자(같은 Apple 계정) — 실행 시 아직 반영 전이면 재조회로 인식해 페이월 없이 바로 켠다. + @Test("didTapEnableSync는 재조회로 Pro가 확인되면 페이월 없이 바로 동기화를 켠다") + func enableSyncEnablesDirectlyWhenRecheckFindsPro() async { + let clock = TestClock() + let isPro = LockIsolated(false) + let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { + OnboardingFeature() + } withDependencies: { + $0.continuousClock = clock + $0.entitlementClient.canEnableICloudSync = { isPro.value } + $0.purchaseClient.refreshEntitlement = { isPro.setValue(true) } + $0.onboardingClient.enableICloudSync = { .available } + } + + await store.send(.didTapEnableSync) { + $0.isEnablingSync = true + } + await store.receive(.entitlementRechecked) + await store.receive(.iCloudSyncStatusResponse(.available)) + await clock.advance(by: .seconds(0.5)) + await store.receive(.enableSyncCompleted) { + $0.step = .syncEnabled + } + await clock.advance(by: .seconds(2)) + await store.receive(.delegate(.completed)) + } + + @Test("페이월에서 구매를 마치면(didFinish) 페이월을 닫고 자동으로 동기화를 켠다") + func paywallDidFinishAutoEnablesSync() async { + let clock = TestClock() + var initial = OnboardingFeature.State(step: .icloudSync) + initial.paywall = DevaultProPaywallFeature.State() + let store = TestStore(initialState: initial) { + OnboardingFeature() + } withDependencies: { + $0.continuousClock = clock + $0.entitlementClient.canEnableICloudSync = { true } + $0.onboardingClient.enableICloudSync = { .available } + } + + await store.send(.paywall(.presented(.delegate(.didFinish)))) { + $0.paywall = nil + $0.isEnablingSync = true + } + await store.receive(.iCloudSyncStatusResponse(.available)) + await clock.advance(by: .seconds(0.5)) + await store.receive(.enableSyncCompleted) { + $0.step = .syncEnabled + } + await clock.advance(by: .seconds(2)) + await store.receive(.delegate(.completed)) + } + @Test("didTapNotNow는 온보딩을 완료 처리한다") func notNowCompletesOnboarding() async { let store = TestStore(initialState: OnboardingFeature.State(step: .icloudSync)) { diff --git a/Projects/Devault/Project.swift b/Projects/Devault/Project.swift index 7f114d79..75ac7db6 100644 --- a/Projects/Devault/Project.swift +++ b/Projects/Devault/Project.swift @@ -16,8 +16,8 @@ let isLocalSigning = Environment.localSigning.getBoolean(default: false) let isCISigning = Environment.ciSigning.getBoolean(default: false) /// 아카이브 빌드 번호(CFBundleVersion). App Store는 업로드마다 고유·증가값을 요구하므로 -/// CI에선 `$CI_BUILD_NUMBER`를 주입하고, 로컬은 기본값 "1"을 쓴다. -let buildNumber = Environment.buildNumber.getString(default: "1") +/// CI에선 `$CI_BUILD_NUMBER`를 주입하고, 로컬은 기본값 "40"을 쓴다. +let buildNumber = Environment.buildNumber.getString(default: "40") /// 로컬 StoreKit 테스트 스토어를 실행 스킴에 붙인다. `generate-storekit`이 `TUIST_STORE_KIT_TESTING=1`로 설정한다. /// diff --git a/Projects/Devault/Resources/Devault.entitlements b/Projects/Devault/Resources/Devault.entitlements index c89864d5..e519a6fd 100644 --- a/Projects/Devault/Resources/Devault.entitlements +++ b/Projects/Devault/Resources/Devault.entitlements @@ -2,6 +2,12 @@ + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.developer.aps-environment + development com.apple.developer.icloud-container-identifiers iCloud.com.devault.app diff --git a/Projects/Devault/Sources/Composition/Dependencies/App/AppLaunchClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/App/AppLaunchClient+Live.swift index 718cb0da..aba3f3db 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/App/AppLaunchClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/App/AppLaunchClient+Live.swift @@ -49,6 +49,16 @@ extension AppLaunchClient: @retroactive DependencyKey { }, setICloudLastUpdateDetectedAt: { date in iCloudSettingsUseCase.setLastUpdateDetectedAt(date) + }, + disableICloudSyncForDowngrade: { + // free로 내려가면 동기화를 끈다. 로컬 데이터는 유지, CloudKit 미러링만 중단. + guard iCloudSettingsUseCase.isEnabled() else { return } + do { + try await iCloudSettingsUseCase.setEnabled(false) + Log.info("[iCloud] 등급 하락으로 동기화 자동 중단", category: .data) + } catch { + Log.warn("등급 하락 시 iCloud 동기화 중단 실패: \(error)", category: .data) + } } ) }() diff --git a/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift b/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift index 4b196fa5..9e71d4e2 100644 --- a/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift +++ b/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift @@ -43,12 +43,20 @@ actor LiveStorage { } } +extension LiveStorage { + /// CloudKit 미러링 여부를 판정한다. iCloud 동기화는 Pro 전용이라 free면 항상 false. + /// 순수 판정만 하고, 플래그 정리는 `setEnabled(false)`가 맡는다. + static func resolveICloudSync(_ repository: any SettingsRepository) -> Bool { + repository.isICloudSyncEnabled() && repository.cachedEntitlement() == .pro + } +} + private extension LiveStorage { private func repositories() throws -> RepositorySet { if let current { return current } let repositorySet = try makeRepositorySet( - iCloudSyncEnabled: settingsRepository.isICloudSyncEnabled() + iCloudSyncEnabled: Self.resolveICloudSync(settingsRepository) ) current = repositorySet return repositorySet diff --git a/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift b/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift new file mode 100644 index 00000000..4ef22b1c --- /dev/null +++ b/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift @@ -0,0 +1,51 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import Testing + +import DVDomain +@testable import Devault +@testable import DVData + +/// iCloud 동기화는 Pro 전용이라, 저장소 구성 지점에서 등급과 함께 판정한다. +/// Pro일 때 켠 플래그가 free로 내려온 뒤에도 남아 컨테이너가 `.private`(CloudKit 미러링)로 만들어지는 것을 막는 게이트를 검증한다. +@Suite("LiveStorage iCloud 게이트") +struct LiveStorageICloudGateTests { + + private func makeSettings() -> SettingsRepositoryImpl { + let suiteName = "LiveStorageICloudGateTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + return SettingsRepositoryImpl(defaults: defaults) + } + + @Test("free면 켜짐 플래그가 남아 있어도 미러링하지 않는다(순수 판정 — 플래그는 건드리지 않음)") + func freeNeverMirrorsRegardlessOfStaleFlag() { + let settings = makeSettings() + settings.setICloudSyncEnabled(true) + settings.setCachedEntitlement(.free) + + #expect(LiveStorage.resolveICloudSync(settings) == false) + // 순수 판정이라 플래그는 그대로 — 정리는 setEnabled(false)/다운그레이드 경로가 맡는다. + #expect(settings.isICloudSyncEnabled() == true) + } + + @Test("Pro면 켜짐 플래그를 그대로 두고 동기화를 유지한다") + func proKeepsSyncEnabled() { + let settings = makeSettings() + settings.setICloudSyncEnabled(true) + settings.setCachedEntitlement(.pro) + + #expect(LiveStorage.resolveICloudSync(settings) == true) + #expect(settings.isICloudSyncEnabled() == true) + } + + @Test("플래그가 꺼져 있으면 등급과 무관하게 동기화하지 않는다") + func disabledFlagStaysDisabled() { + let settings = makeSettings() + settings.setICloudSyncEnabled(false) + settings.setCachedEntitlement(.pro) + + #expect(LiveStorage.resolveICloudSync(settings) == false) + #expect(settings.isICloudSyncEnabled() == false) + } +}