From 1303f74c3e50b20e60aa6191007719c93bc1da3a Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 00:49:25 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[#139]=20fix:=20iCloud=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20Pro=20=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B2=8C=EC=9D=B4=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LiveStorage가 컨테이너 생성 시 등급을 함께 봐, free면 남아 있던 켜짐 플래그를 끄고 로컬로 구성 - 실행 중 Pro→free 전환 시 iCloud 동기화를 자동 종료(로컬·CloudKit 데이터는 유지) - LiveStorage 게이트·전환 종료 단위 테스트 추가 --- .../Dependencies/App/AppLaunchClient.swift | 5 +- .../Sources/Features/AppFeature.swift | 8 ++- .../Tests/AppFeatureTests.swift | 39 +++++++++++++++ .../App/AppLaunchClient+Live.swift | 10 ++++ .../Composition/Graph/LiveStorage.swift | 14 +++++- .../Storage/LiveStorageICloudGateTests.swift | 50 +++++++++++++++++++ 6 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift 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..baa74bed 100644 --- a/Projects/DVPresentation/Sources/Features/AppFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AppFeature.swift @@ -152,7 +152,13 @@ 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 .iCloudRemoteChangeHandled: guard state.main != nil else { return .none } diff --git a/Projects/DVPresentation/Tests/AppFeatureTests.swift b/Projects/DVPresentation/Tests/AppFeatureTests.swift index 018c22a0..ebfa6b06 100644 --- a/Projects/DVPresentation/Tests/AppFeatureTests.swift +++ b/Projects/DVPresentation/Tests/AppFeatureTests.swift @@ -272,6 +272,45 @@ 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) + } + @Test("잠금 해제하면 새 앱 비활성 감시를 시작한다") func unlockStartsNewInactivityMonitoring() async { var initial = AppFeature.State() 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..69de30cd 100644 --- a/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift +++ b/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift @@ -43,12 +43,24 @@ actor LiveStorage { } } +extension LiveStorage { + /// iCloud 동기화는 Pro 전용 — free인데 켜짐 플래그가 남아 있으면 끄고 로컬로 구성한다(데이터는 유지). + static func resolveICloudSync(_ repository: any SettingsRepository) -> Bool { + guard repository.isICloudSyncEnabled() else { return false } + guard repository.cachedEntitlement() == .pro else { + repository.setICloudSyncEnabled(false) + return false + } + return true + } +} + 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..488940f9 --- /dev/null +++ b/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift @@ -0,0 +1,50 @@ +// 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 freeWithStaleFlagIsDisabledAndReconciled() { + let settings = makeSettings() + settings.setICloudSyncEnabled(true) + settings.setCachedEntitlement(.free) + + #expect(LiveStorage.resolveICloudSync(settings) == false) + #expect(settings.isICloudSyncEnabled() == false) // 실제 상태(미러링 없음)에 맞춰 정리됨 + } + + @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) + } +} From 47ae36f9061c0baacedb8fb0834c8eecdda8a712 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 00:49:49 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[#139]=20feat:=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20Enable=20Sync=EB=A5=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=9B=94=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - free 사용자가 Enable Sync를 누르면 업그레이드 페이월을 표시 - 이미 Pro(다른 기기 구독 포함)면 페이월 없이 바로 동기화를 켬 - 구매·복원 성공 시 자동으로 동기화를 켜고 온보딩을 이어감 --- .../Onboarding/OnboardingFeature.swift | 53 ++++++++++++-- .../Features/Onboarding/OnboardingView.swift | 3 + .../Onboarding/OnboardingFeatureTests.swift | 71 +++++++++++++++++++ 3 files changed, 121 insertions(+), 6 deletions(-) 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/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)) { From 3830f631c0c3da2f0e256bf81fca031042966473 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 00:50:14 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[#139]=20chore:=20App=20Sandbox=C2=B7CloudK?= =?UTF-8?q?it=20=EC=97=94=ED=83=80=EC=9D=B4=ED=8B=80=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=2040?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App Store 배포용 App Sandbox 활성화 - CloudKit 동기화·푸시용 network.client·aps-environment 엔타이틀먼트 추가 - 로컬 아카이브 빌드 번호 기본값 40 --- Projects/Devault/Project.swift | 4 ++-- Projects/Devault/Resources/Devault.entitlements | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) 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..fdc7d2e6 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 + + aps-environment + development com.apple.developer.icloud-container-identifiers iCloud.com.devault.app From 3f0b18b2028d0ea71f0a3d8da42e1a880939602b Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 01:07:37 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[#139]=20fix:=20HelpMenuLinkTests,=20Entitl?= =?UTF-8?q?ementClient=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B9=A8=EC=A7=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Dependencies/Entitlement/EntitlementClient.swift | 2 ++ Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift b/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift index 1db40650..231d5ec2 100644 --- a/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift @@ -39,6 +39,8 @@ extension EntitlementClient: TestDependencyKey { /// `async throws` 판정은 그대로 둔다 — 덮어쓰지 않은 테스트에서 실패해 의존이 드러난다. public static var testValue: EntitlementClient { var client = EntitlementClient() + // 등급을 표시용으로만 읽는 곳(예: 피드백 메일의 Plan 줄)이 미구현 호출로 깨지지 않게 기본값을 연다. 등급을 검증하는 테스트가 덮어쓴다. + client.current = { .free } client.canEnableICloudSync = { true } client.canUseMultipleExpiryAlertDays = { true } // 화면이 `.task`에서 구독하므로, 비워 두면 게이트와 무관한 테스트가 미구현 호출로 먼저 깨진다. 즉시 끝나는 스트림이라 등급 변경은 오지 않는다. diff --git a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift index 907aec94..0eee574a 100644 --- a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift +++ b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift @@ -3,6 +3,7 @@ import Testing @testable import DVPresentation +import Foundation @Suite("HelpMenuLink") struct HelpMenuLinkTests { From c6ae0c63a7a75c2129c822bf30b9427f209c3486 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 03:34:09 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[#139]=20fix:=20iCloud=20Pro=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8C=85=C2=B7=EB=8B=A4=EC=9A=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=B0=A8=EB=8B=A8=EC=9D=84=20fail-safe?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setEnabled(false)는 저장소 전환이 실패해도 플래그를 먼저 false로 확정(fail-safe) - LiveStorage.resolveICloudSync를 순수 판정으로 정리(구성 경로의 부작용 제거) - 앱 꺼진 사이 만료돼 free로 시작하면 등급 스트림 첫 방출에서도 동기화 강제 종료 - 관련 단위 테스트 추가 --- .../Settings/ICloudSettingsUseCaseImpl.swift | 11 ++++++-- .../ICloudSettingsUseCaseImplTests.swift | 20 ++++++++++++++ .../Sources/Features/AppFeature.swift | 16 ++++++++--- .../Tests/AppFeatureTests.swift | 27 +++++++++++++++++++ .../Composition/Graph/LiveStorage.swift | 10 +++---- .../Storage/LiveStorageICloudGateTests.swift | 7 ++--- 6 files changed, 75 insertions(+), 16 deletions(-) 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/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/Sources/Features/AppFeature.swift b/Projects/DVPresentation/Sources/Features/AppFeature.swift index baa74bed..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 @@ -160,6 +161,11 @@ public struct AppFeature { : .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 } return .send(.main(.iCloudRemoteChangeDetected)) @@ -250,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/Tests/AppFeatureTests.swift b/Projects/DVPresentation/Tests/AppFeatureTests.swift index ebfa6b06..d0701534 100644 --- a/Projects/DVPresentation/Tests/AppFeatureTests.swift +++ b/Projects/DVPresentation/Tests/AppFeatureTests.swift @@ -311,6 +311,33 @@ struct AppFeatureTests { #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/Devault/Sources/Composition/Graph/LiveStorage.swift b/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift index 69de30cd..9e71d4e2 100644 --- a/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift +++ b/Projects/Devault/Sources/Composition/Graph/LiveStorage.swift @@ -44,14 +44,10 @@ actor LiveStorage { } extension LiveStorage { - /// iCloud 동기화는 Pro 전용 — free인데 켜짐 플래그가 남아 있으면 끄고 로컬로 구성한다(데이터는 유지). + /// CloudKit 미러링 여부를 판정한다. iCloud 동기화는 Pro 전용이라 free면 항상 false. + /// 순수 판정만 하고, 플래그 정리는 `setEnabled(false)`가 맡는다. static func resolveICloudSync(_ repository: any SettingsRepository) -> Bool { - guard repository.isICloudSyncEnabled() else { return false } - guard repository.cachedEntitlement() == .pro else { - repository.setICloudSyncEnabled(false) - return false - } - return true + repository.isICloudSyncEnabled() && repository.cachedEntitlement() == .pro } } diff --git a/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift b/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift index 488940f9..4ef22b1c 100644 --- a/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift +++ b/Projects/Devault/Tests/Storage/LiveStorageICloudGateTests.swift @@ -18,14 +18,15 @@ struct LiveStorageICloudGateTests { return SettingsRepositoryImpl(defaults: defaults) } - @Test("free인데 켜짐 플래그가 남아 있으면 동기화를 끄고 플래그를 정리한다") - func freeWithStaleFlagIsDisabledAndReconciled() { + @Test("free면 켜짐 플래그가 남아 있어도 미러링하지 않는다(순수 판정 — 플래그는 건드리지 않음)") + func freeNeverMirrorsRegardlessOfStaleFlag() { let settings = makeSettings() settings.setICloudSyncEnabled(true) settings.setCachedEntitlement(.free) #expect(LiveStorage.resolveICloudSync(settings) == false) - #expect(settings.isICloudSyncEnabled() == false) // 실제 상태(미러링 없음)에 맞춰 정리됨 + // 순수 판정이라 플래그는 그대로 — 정리는 setEnabled(false)/다운그레이드 경로가 맡는다. + #expect(settings.isICloudSyncEnabled() == true) } @Test("Pro면 켜짐 플래그를 그대로 두고 동기화를 유지한다") From cb8c5c3367cce88e502b401770ce951466ea6656 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 03:34:36 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[#139]=20fix:=20macOS=20APNs=20entitlement?= =?UTF-8?q?=20=ED=82=A4=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aps-environment(iOS 키) → com.apple.developer.aps-environment(macOS 키)로 변경 - CloudKit 실시간 동기화 푸시 등록이 정상 동작하도록. 환경은 프로비저닝 프로파일이 결정 --- Projects/Devault/Resources/Devault.entitlements | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Devault/Resources/Devault.entitlements b/Projects/Devault/Resources/Devault.entitlements index fdc7d2e6..e519a6fd 100644 --- a/Projects/Devault/Resources/Devault.entitlements +++ b/Projects/Devault/Resources/Devault.entitlements @@ -6,7 +6,7 @@ com.apple.security.network.client - aps-environment + com.apple.developer.aps-environment development com.apple.developer.icloud-container-identifiers From 62cec7ffb49eb843a94129c3e050719a6f35b2c8 Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 03:35:05 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[#139]=20fix:=20=EC=98=88=EC=95=BD=EB=90=9C?= =?UTF-8?q?=20=ED=94=8C=EB=9E=9C=20=EB=B3=80=EA=B2=BD=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=A0=95=ED=99=95=EB=8F=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동 갱신이 꺼져 있으면 예약 상품이 남아 있어도 변경 예약이 아닌 만료로 처리(willAutoRenew 게이팅) - 구독 상태 캐시가 renewalProductID를 보존하도록 — 구매 직후 예약 배지 유실 방지 - 예약 변경 안내를 갱신일 유무와 분리(날짜 없어도 예약 사실은 안내) - 관련 테스트 추가 --- .../Settings/SettingsRepositoryImpl.swift | 8 ++- .../Settings/UserDefaultsKey.swift | 1 + .../Purchase/PurchaseServiceImpl.swift | 3 +- ...RepositoryImplSubscriptionCacheTests.swift | 52 +++++++++++++++++++ .../Sources/Entity/SubscriptionStatus.swift | 3 +- .../Core/Entity/SubscriptionStatusTests.swift | 11 ++++ .../Resources/Localizable.xcstrings | 11 ++++ .../DevaultPro/DevaultProSettingsView.swift | 8 ++- 8 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 Projects/DVData/Tests/RepositoryImpl/Settings/SettingsRepositoryImplSubscriptionCacheTests.swift 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/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/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/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) From f8dbad7ec60207840206ac783365a74305ea602d Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 03:36:53 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[#139]=20test:=20EntitlementClient=20testVa?= =?UTF-8?q?lue=EC=9D=98=20current=20=EC=95=88=EC=A0=84=EB=A7=9D=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - testValue.current를 미구현으로 되돌려, 등급을 읽는 코드의 미선언 의존을 다시 드러냄 - HelpMenuLinkTests는 withDependencies로 등급을 명시해 통과 --- .../Dependencies/Entitlement/EntitlementClient.swift | 2 -- .../Tests/AppMenu/HelpMenuLinkTests.swift | 10 ++++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift b/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift index 231d5ec2..1db40650 100644 --- a/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/Entitlement/EntitlementClient.swift @@ -39,8 +39,6 @@ extension EntitlementClient: TestDependencyKey { /// `async throws` 판정은 그대로 둔다 — 덮어쓰지 않은 테스트에서 실패해 의존이 드러난다. public static var testValue: EntitlementClient { var client = EntitlementClient() - // 등급을 표시용으로만 읽는 곳(예: 피드백 메일의 Plan 줄)이 미구현 호출로 깨지지 않게 기본값을 연다. 등급을 검증하는 테스트가 덮어쓴다. - client.current = { .free } client.canEnableICloudSync = { true } client.canUseMultipleExpiryAlertDays = { true } // 화면이 `.task`에서 구독하므로, 비워 두면 게이트와 무관한 테스트가 미구현 호출로 먼저 깨진다. 즉시 끝나는 스트림이라 등급 변경은 오지 않는다. diff --git a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift index 0eee574a..e7abf673 100644 --- a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift +++ b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift @@ -1,9 +1,10 @@ // Copyright © 2026 Devault. All rights reserved +import ComposableArchitecture +import Foundation import Testing @testable import DVPresentation -import Foundation @Suite("HelpMenuLink") struct HelpMenuLinkTests { @@ -30,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") From c6a013621308f5f44049917c73c0007d6c86deeb Mon Sep 17 00:00:00 2001 From: Hyeon-Ju Date: Sat, 5 Sep 2026 04:01:03 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[#139]=20test:=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20URL=EC=9D=98=20Plan=20=EA=B0=92=EC=9D=B4=20?= =?UTF-8?q?=EB=93=B1=EA=B8=89=EC=9D=84=20=EB=B0=98=EC=98=81=ED=95=98?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라벨(Plan:)만이 아니라 값까지 확인: .free→"Plan: Free" - .pro로도 평가해 값이 달라지는지 검증 — current()를 실제로 읽지 않으면 실패하도록 --- .../Tests/AppMenu/HelpMenuLinkTests.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift index e7abf673..37c2af3a 100644 --- a/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift +++ b/Projects/DVPresentation/Tests/AppMenu/HelpMenuLinkTests.swift @@ -49,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")) } }