Add tests for 1.1.0 features

This commit is contained in:
alexandrev-tibco
2026-02-01 14:40:10 +01:00
parent d55b999bef
commit 2437dd647f
11 changed files with 297 additions and 29 deletions
+41 -11
View File
@@ -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> = 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> = Category.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
return (try? context.fetch(request)) ?? []
@@ -49,6 +49,13 @@ class IAPService: ObservableObject {
}
}
#if DEBUG
func setPremiumForTesting(_ value: Bool, familyShared: Bool = false) {
isPremium = value
isFamilyShared = familyShared
}
#endif
deinit {
updateListenerTask?.cancel()
}
@@ -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)
}
}
@@ -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 }
@@ -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)
}
@@ -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
@@ -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))
}
}
@@ -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)
}
}
@@ -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"))
}
}
@@ -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"))
}
}
@@ -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)
}
}