From 2437dd647fe3887116a8e9f0977acb98571fdb57 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Sun, 1 Feb 2026 14:40:10 +0100 Subject: [PATCH] Add tests for 1.1.0 features --- PortfolioJournal/Services/BackupService.swift | 52 +++++++++++---- PortfolioJournal/Services/IAPService.swift | 7 ++ .../Services/ReviewPromptService.swift | 37 ++++++++--- PortfolioJournal/Services/ShareService.swift | 12 ++++ .../Views/Dashboard/MonthlyCheckInView.swift | 13 ++-- .../Views/Onboarding/OnboardingView.swift | 6 +- .../Services/BackupServiceTests.swift | 65 +++++++++++++++++++ .../Services/ReviewPromptServiceTests.swift | 54 +++++++++++++++ .../Services/ShareServiceTests.swift | 28 ++++++++ .../ViewModels/SettingsViewModelTests.swift | 42 ++++++++++++ .../Views/OnboardingViewTests.swift | 10 +++ 11 files changed, 297 insertions(+), 29 deletions(-) create mode 100644 PortfolioJournalTests/Services/BackupServiceTests.swift create mode 100644 PortfolioJournalTests/Services/ReviewPromptServiceTests.swift create mode 100644 PortfolioJournalTests/Services/ShareServiceTests.swift create mode 100644 PortfolioJournalTests/ViewModels/SettingsViewModelTests.swift create mode 100644 PortfolioJournalTests/Views/OnboardingViewTests.swift diff --git a/PortfolioJournal/Services/BackupService.swift b/PortfolioJournal/Services/BackupService.swift index 469bb7a..6a4e79e 100644 --- a/PortfolioJournal/Services/BackupService.swift +++ b/PortfolioJournal/Services/BackupService.swift @@ -17,7 +17,11 @@ struct BackupRecord: Identifiable { class BackupService { static let shared = BackupService() - private let fileManager = FileManager.default + private let fileManager: FileManager + private let dateProvider: () -> Date + private let localBaseDirectoryProvider: () -> URL? + private let iCloudBaseDirectoryProvider: () -> URL? + private let exportProvider: () -> String private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd-HHmmss" @@ -25,16 +29,42 @@ class BackupService { return formatter }() - private init() {} + private init() { + fileManager = .default + dateProvider = Date.init + localBaseDirectoryProvider = { [fileManager] in + fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + } + iCloudBaseDirectoryProvider = { [fileManager] in + fileManager.url(forUbiquityContainerIdentifier: nil) + } + exportProvider = { + let context = CoreDataStack.shared.viewContext + let sources = BackupService.fetchAllSources(in: context) + let categories = BackupService.fetchAllCategories(in: context) + return ExportService.shared.exportToJSON(sources: sources, categories: categories) + } + } + + init( + fileManager: FileManager = .default, + dateProvider: @escaping () -> Date, + localBaseDirectoryProvider: @escaping () -> URL?, + iCloudBaseDirectoryProvider: @escaping () -> URL?, + exportProvider: @escaping () -> String + ) { + self.fileManager = fileManager + self.dateProvider = dateProvider + self.localBaseDirectoryProvider = localBaseDirectoryProvider + self.iCloudBaseDirectoryProvider = iCloudBaseDirectoryProvider + self.exportProvider = exportProvider + } func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] { - let timestamp = dateFormatter.string(from: Date()) + let timestamp = dateFormatter.string(from: dateProvider()) let fileName = "backup-\(timestamp).json" - let context = CoreDataStack.shared.viewContext - let sources = fetchAllSources(in: context) - let categories = fetchAllCategories(in: context) - let content = ExportService.shared.exportToJSON(sources: sources, categories: categories) + let content = exportProvider() var records: [BackupRecord] = [] @@ -67,7 +97,7 @@ class BackupService { } private func backupDirectory() -> URL? { - guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + guard let base = localBaseDirectoryProvider() else { return nil } let dir = base.appendingPathComponent("Backups", isDirectory: true) @@ -76,7 +106,7 @@ class BackupService { } private func iCloudBackupDirectory() -> URL? { - guard let base = fileManager.url(forUbiquityContainerIdentifier: nil) else { return nil } + guard let base = iCloudBaseDirectoryProvider() else { return nil } let dir = base.appendingPathComponent("Documents/Backups", isDirectory: true) ensureDirectoryExists(dir) return dir @@ -137,13 +167,13 @@ class BackupService { return dateFormatter.date(from: dateString) } - private func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] { + private static func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] { let request: NSFetchRequest = InvestmentSource.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)] return (try? context.fetch(request)) ?? [] } - private func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] { + private static func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] { let request: NSFetchRequest = Category.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)] return (try? context.fetch(request)) ?? [] diff --git a/PortfolioJournal/Services/IAPService.swift b/PortfolioJournal/Services/IAPService.swift index bfa2652..0341adc 100644 --- a/PortfolioJournal/Services/IAPService.swift +++ b/PortfolioJournal/Services/IAPService.swift @@ -49,6 +49,13 @@ class IAPService: ObservableObject { } } +#if DEBUG + func setPremiumForTesting(_ value: Bool, familyShared: Bool = false) { + isPremium = value + isFamilyShared = familyShared + } +#endif + deinit { updateListenerTask?.cancel() } diff --git a/PortfolioJournal/Services/ReviewPromptService.swift b/PortfolioJournal/Services/ReviewPromptService.swift index 17a3f80..9b255df 100644 --- a/PortfolioJournal/Services/ReviewPromptService.swift +++ b/PortfolioJournal/Services/ReviewPromptService.swift @@ -8,38 +8,59 @@ final class ReviewPromptService { private let checkInCountKey = "reviewPromptCheckinCount" private let minCheckInsBetweenPrompts = 3 private let minDaysBetweenPrompts = 90 + private let userDefaults: UserDefaults + private let dateProvider: () -> Date + private let reviewRequestHandler: () -> Void - private init() {} + private init() { + userDefaults = .standard + dateProvider = Date.init + reviewRequestHandler = ReviewPromptService.defaultReviewRequestHandler + } + + init( + userDefaults: UserDefaults, + dateProvider: @escaping () -> Date, + reviewRequestHandler: @escaping () -> Void + ) { + self.userDefaults = userDefaults + self.dateProvider = dateProvider + self.reviewRequestHandler = reviewRequestHandler + } func recordMonthlyCheckInCompleted() { - let currentCount = UserDefaults.standard.integer(forKey: checkInCountKey) - UserDefaults.standard.set(currentCount + 1, forKey: checkInCountKey) + let currentCount = userDefaults.integer(forKey: checkInCountKey) + userDefaults.set(currentCount + 1, forKey: checkInCountKey) requestReviewIfEligible() } private func requestReviewIfEligible() { - let now = Date() - if let lastPrompt = UserDefaults.standard.object(forKey: lastPromptKey) as? Date { + let now = dateProvider() + if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date { let daysSince = now.timeIntervalSince(lastPrompt) / 86_400 if daysSince < Double(minDaysBetweenPrompts) { return } } - let count = UserDefaults.standard.integer(forKey: checkInCountKey) + let count = userDefaults.integer(forKey: checkInCountKey) guard count >= minCheckInsBetweenPrompts else { return } requestReview() } private func requestReview() { + reviewRequestHandler() + userDefaults.set(dateProvider(), forKey: lastPromptKey) + userDefaults.set(0, forKey: checkInCountKey) + } + + private static func defaultReviewRequestHandler() { guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } if #available(iOS 18.0, *) { AppStore.requestReview(in: scene) } else { SKStoreReviewController.requestReview(in: scene) } - UserDefaults.standard.set(Date(), forKey: lastPromptKey) - UserDefaults.standard.set(0, forKey: checkInCountKey) } } diff --git a/PortfolioJournal/Services/ShareService.swift b/PortfolioJournal/Services/ShareService.swift index 5859f3e..47a7c8c 100644 --- a/PortfolioJournal/Services/ShareService.swift +++ b/PortfolioJournal/Services/ShareService.swift @@ -6,6 +6,18 @@ class ShareService { private init() {} + static func buildMonthlyCheckInShareText(summary: MonthlySummary, appName: String) -> String { + """ + \(summary.periodLabel) Check-in + Starting: \(summary.formattedStartingValue) + Ending: \(summary.formattedEndingValue) + Contributions: \(summary.formattedContributions) + Net performance: \(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage)) + + Shared from \(appName) + """ + } + func shareText(_ content: String) { guard let viewController = ShareService.topViewController() else { return } diff --git a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift index b559375..b54d5e7 100644 --- a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift +++ b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift @@ -457,15 +457,10 @@ struct MonthlyCheckInView: View { private func shareMonthlyCheckIn() { let summary = viewModel.monthlySummary - let text = """ - \(summary.periodLabel) Check-in - Starting: \(summary.formattedStartingValue) - Ending: \(summary.formattedEndingValue) - Contributions: \(summary.formattedContributions) - Net performance: \(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage)) - - Shared from \(appDisplayName) - """ + let text = ShareService.buildMonthlyCheckInShareText( + summary: summary, + appName: appDisplayName + ) ShareService.shared.shareText(text) } diff --git a/PortfolioJournal/Views/Onboarding/OnboardingView.swift b/PortfolioJournal/Views/Onboarding/OnboardingView.swift index 207732a..d2a50a5 100644 --- a/PortfolioJournal/Views/Onboarding/OnboardingView.swift +++ b/PortfolioJournal/Views/Onboarding/OnboardingView.swift @@ -317,9 +317,13 @@ struct OnboardingQuickStartView: View { } private func openAppSettings() { - guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + guard let url = Self.appSettingsURL() else { return } UIApplication.shared.open(url) } + + static func appSettingsURL() -> URL? { + URL(string: UIApplication.openSettingsURLString) + } } // MARK: - First Launch Welcome diff --git a/PortfolioJournalTests/Services/BackupServiceTests.swift b/PortfolioJournalTests/Services/BackupServiceTests.swift new file mode 100644 index 0000000..c76530a --- /dev/null +++ b/PortfolioJournalTests/Services/BackupServiceTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import PortfolioJournal + +final class BackupServiceTests: XCTestCase { + private var tempRoot: URL! + + override func setUp() { + super.setUp() + tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + } + + override func tearDown() { + if let tempRoot { + try? FileManager.default.removeItem(at: tempRoot) + } + super.tearDown() + } + + func testCreateBackupPrunesToRetentionCount() { + let localBase = tempRoot.appendingPathComponent("Local", isDirectory: true) + try? FileManager.default.createDirectory(at: localBase, withIntermediateDirectories: true) + + var dates = [ + Date(timeIntervalSince1970: 100), + Date(timeIntervalSince1970: 200) + ] + + let service = BackupService( + fileManager: .default, + dateProvider: { dates.removeFirst() }, + localBaseDirectoryProvider: { localBase }, + iCloudBaseDirectoryProvider: { nil }, + exportProvider: { "{}" } + ) + + _ = service.createBackup(retentionCount: 1, includeICloud: false) + let records = service.createBackup(retentionCount: 1, includeICloud: false) + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records.first?.location, .local) + } + + func testCreateBackupIncludesICloudWhenEnabled() { + let localBase = tempRoot.appendingPathComponent("Local", isDirectory: true) + let iCloudBase = tempRoot.appendingPathComponent("iCloud", isDirectory: true) + try? FileManager.default.createDirectory(at: localBase, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: iCloudBase, withIntermediateDirectories: true) + + let service = BackupService( + fileManager: .default, + dateProvider: { Date(timeIntervalSince1970: 300) }, + localBaseDirectoryProvider: { localBase }, + iCloudBaseDirectoryProvider: { iCloudBase }, + exportProvider: { "{}" } + ) + + let records = service.createBackup(retentionCount: 5, includeICloud: true) + let locations = Set(records.map { $0.location }) + + XCTAssertTrue(locations.contains(.local)) + XCTAssertTrue(locations.contains(.iCloud)) + } +} diff --git a/PortfolioJournalTests/Services/ReviewPromptServiceTests.swift b/PortfolioJournalTests/Services/ReviewPromptServiceTests.swift new file mode 100644 index 0000000..6c3c93e --- /dev/null +++ b/PortfolioJournalTests/Services/ReviewPromptServiceTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import PortfolioJournal + +final class ReviewPromptServiceTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = UUID().uuidString + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + if let suiteName { + defaults.removePersistentDomain(forName: suiteName) + } + super.tearDown() + } + + func testTriggersAfterMinimumCheckIns() { + var didRequest = false + let service = ReviewPromptService( + userDefaults: defaults, + dateProvider: { Date(timeIntervalSince1970: 1_000) }, + reviewRequestHandler: { didRequest = true } + ) + + service.recordMonthlyCheckInCompleted() + service.recordMonthlyCheckInCompleted() + XCTAssertFalse(didRequest) + + service.recordMonthlyCheckInCompleted() + XCTAssertTrue(didRequest) + } + + func testDoesNotTriggerIfPromptedRecently() { + var didRequest = false + let now = Date(timeIntervalSince1970: 10_000) + defaults.set(now, forKey: "reviewPromptLastDate") + + let service = ReviewPromptService( + userDefaults: defaults, + dateProvider: { now.addingTimeInterval(60 * 60 * 24 * 10) }, + reviewRequestHandler: { didRequest = true } + ) + + service.recordMonthlyCheckInCompleted() + service.recordMonthlyCheckInCompleted() + service.recordMonthlyCheckInCompleted() + + XCTAssertFalse(didRequest) + } +} diff --git a/PortfolioJournalTests/Services/ShareServiceTests.swift b/PortfolioJournalTests/Services/ShareServiceTests.swift new file mode 100644 index 0000000..8832d4f --- /dev/null +++ b/PortfolioJournalTests/Services/ShareServiceTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import PortfolioJournal + +final class ShareServiceTests: XCTestCase { + func testBuildMonthlyCheckInShareText() { + let summary = MonthlySummary( + periodLabel: "January 2026", + startDate: Date(timeIntervalSince1970: 0), + endDate: Date(timeIntervalSince1970: 0), + startingValue: 1000, + endingValue: 1200, + contributions: 150, + netPerformance: 50 + ) + + let text = ShareService.buildMonthlyCheckInShareText( + summary: summary, + appName: "Portfolio Journal" + ) + + XCTAssertTrue(text.contains("January 2026 Check-in")) + XCTAssertTrue(text.contains("Starting:")) + XCTAssertTrue(text.contains("Ending:")) + XCTAssertTrue(text.contains("Contributions:")) + XCTAssertTrue(text.contains("Net performance:")) + XCTAssertTrue(text.contains("Shared from Portfolio Journal")) + } +} diff --git a/PortfolioJournalTests/ViewModels/SettingsViewModelTests.swift b/PortfolioJournalTests/ViewModels/SettingsViewModelTests.swift new file mode 100644 index 0000000..928cc24 --- /dev/null +++ b/PortfolioJournalTests/ViewModels/SettingsViewModelTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import PortfolioJournal + +@MainActor +final class SettingsViewModelTests: XCTestCase { + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "backupsEnabled") + UserDefaults.standard.removeObject(forKey: "backupRetentionCount") + UserDefaults.standard.removeObject(forKey: "debugPremiumOverride") + super.tearDown() + } + + func testSetBackupsEnabledNonPremiumShowsPaywall() { + let iap = IAPService() +#if DEBUG + iap.setPremiumForTesting(false) +#endif + let viewModel = SettingsViewModel(iapService: iap) + + viewModel.setBackupsEnabled(true) + + XCTAssertFalse(viewModel.backupsEnabled) + XCTAssertTrue(viewModel.showingPaywall) + } + + func testSetBackupsEnabledPremiumPersistsFlag() { +#if DEBUG + UserDefaults.standard.set(true, forKey: "debugPremiumOverride") +#endif + let iap = IAPService() +#if DEBUG + iap.setDebugPremiumOverride(true) +#endif + let viewModel = SettingsViewModel(iapService: iap) + + viewModel.setBackupsEnabled(true) + + XCTAssertTrue(viewModel.backupsEnabled) + XCTAssertFalse(viewModel.showingPaywall) + XCTAssertTrue(UserDefaults.standard.bool(forKey: "backupsEnabled")) + } +} diff --git a/PortfolioJournalTests/Views/OnboardingViewTests.swift b/PortfolioJournalTests/Views/OnboardingViewTests.swift new file mode 100644 index 0000000..6223b27 --- /dev/null +++ b/PortfolioJournalTests/Views/OnboardingViewTests.swift @@ -0,0 +1,10 @@ +import XCTest +import UIKit +@testable import PortfolioJournal + +final class OnboardingViewTests: XCTestCase { + func testAppSettingsURLMatchesSystemConstant() { + let expected = URL(string: UIApplication.openSettingsURLString) + XCTAssertEqual(OnboardingQuickStartView.appSettingsURL(), expected) + } +}