feat(store): add In-App Purchase system with Pro subscription
Implement freemium model with StoreKit 2: - StoreManager singleton for purchase/restore/entitlements - ProFeature enum defining gated features - PaywallView and OnboardingPaywallView for upsell UI - ProGate view modifier and ProBadge component Feature gating: - Trip saving: 1 free trip, then requires Pro - PDF export: Pro only with badge indicator - Progress tab: Shows ProLockedView for free users - Settings: Subscription management section Also fixes pre-existing test issues with StadiumVisit and ItineraryOption model signature changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,16 +27,18 @@ final class GamesHistoryViewModelTests: XCTestCase {
|
||||
stadiumId: "stadium-1",
|
||||
stadiumNameAtVisit: "Stadium 2026",
|
||||
visitDate: Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 15))!,
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
let visit2025 = StadiumVisit(
|
||||
stadiumId: "stadium-2",
|
||||
stadiumNameAtVisit: "Stadium 2025",
|
||||
visitDate: Calendar.current.date(from: DateComponents(year: 2025, month: 6, day: 15))!,
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
modelContext.insert(visit2026)
|
||||
@@ -60,8 +62,9 @@ final class GamesHistoryViewModelTests: XCTestCase {
|
||||
stadiumId: "yankee-stadium",
|
||||
stadiumNameAtVisit: "Yankee Stadium",
|
||||
visitDate: Date(),
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
modelContext.insert(mlbVisit)
|
||||
@@ -83,16 +86,18 @@ final class GamesHistoryViewModelTests: XCTestCase {
|
||||
stadiumId: "stadium-1",
|
||||
stadiumNameAtVisit: "Old Stadium",
|
||||
visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
let newVisit = StadiumVisit(
|
||||
stadiumId: "stadium-2",
|
||||
stadiumNameAtVisit: "New Stadium",
|
||||
visitDate: Date(),
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
modelContext.insert(oldVisit)
|
||||
|
||||
@@ -29,24 +29,27 @@ final class VisitListTests: XCTestCase {
|
||||
stadiumId: stadiumId,
|
||||
stadiumNameAtVisit: "Yankee Stadium",
|
||||
visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
let visit2 = StadiumVisit(
|
||||
stadiumId: stadiumId,
|
||||
stadiumNameAtVisit: "Yankee Stadium",
|
||||
visitDate: Date().addingTimeInterval(-86400 * 7), // 7 days ago
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
let visit3 = StadiumVisit(
|
||||
stadiumId: stadiumId,
|
||||
stadiumNameAtVisit: "Yankee Stadium",
|
||||
visitDate: Date(), // today
|
||||
sport: .mlb,
|
||||
visitType: .tour,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
|
||||
modelContext.insert(visit1)
|
||||
@@ -77,8 +80,9 @@ final class VisitListTests: XCTestCase {
|
||||
stadiumId: stadium1,
|
||||
stadiumNameAtVisit: "Yankee Stadium",
|
||||
visitDate: Date().addingTimeInterval(Double(-i * 86400)),
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
modelContext.insert(visit)
|
||||
}
|
||||
@@ -87,8 +91,9 @@ final class VisitListTests: XCTestCase {
|
||||
stadiumId: stadium2,
|
||||
stadiumNameAtVisit: "Fenway Park",
|
||||
visitDate: Date(),
|
||||
sport: .mlb,
|
||||
visitType: .game,
|
||||
dataSource: .manual
|
||||
dataSource: .fullyManual
|
||||
)
|
||||
modelContext.insert(fenwayVisit)
|
||||
try modelContext.save()
|
||||
|
||||
35
SportsTimeTests/Store/ProFeatureTests.swift
Normal file
35
SportsTimeTests/Store/ProFeatureTests.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// ProFeatureTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import SportsTime
|
||||
|
||||
struct ProFeatureTests {
|
||||
@Test func allCases_containsExpectedFeatures() {
|
||||
let features = ProFeature.allCases
|
||||
#expect(features.contains(.unlimitedTrips))
|
||||
#expect(features.contains(.pdfExport))
|
||||
#expect(features.contains(.progressTracking))
|
||||
#expect(features.count == 3)
|
||||
}
|
||||
|
||||
@Test func displayName_returnsHumanReadableString() {
|
||||
#expect(ProFeature.unlimitedTrips.displayName == "Unlimited Trips")
|
||||
#expect(ProFeature.pdfExport.displayName == "PDF Export")
|
||||
#expect(ProFeature.progressTracking.displayName == "Progress Tracking")
|
||||
}
|
||||
|
||||
@Test func description_returnsMarketingCopy() {
|
||||
#expect(ProFeature.unlimitedTrips.description.contains("trips"))
|
||||
#expect(ProFeature.pdfExport.description.contains("PDF"))
|
||||
#expect(ProFeature.progressTracking.description.contains("stadium"))
|
||||
}
|
||||
|
||||
@Test func icon_returnsValidSFSymbol() {
|
||||
#expect(!ProFeature.unlimitedTrips.icon.isEmpty)
|
||||
#expect(!ProFeature.pdfExport.icon.isEmpty)
|
||||
#expect(!ProFeature.progressTracking.icon.isEmpty)
|
||||
}
|
||||
}
|
||||
16
SportsTimeTests/Store/ProGateTests.swift
Normal file
16
SportsTimeTests/Store/ProGateTests.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// ProGateTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
|
||||
import Testing
|
||||
import SwiftUI
|
||||
@testable import SportsTime
|
||||
|
||||
struct ProGateTests {
|
||||
@Test func proGate_createsViewModifier() {
|
||||
// Just verify the modifier compiles and can be applied
|
||||
let _ = Text("Test").proGate(feature: .pdfExport)
|
||||
#expect(true) // If we got here, it compiles
|
||||
}
|
||||
}
|
||||
16
SportsTimeTests/Store/StoreErrorTests.swift
Normal file
16
SportsTimeTests/Store/StoreErrorTests.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// StoreErrorTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import SportsTime
|
||||
|
||||
struct StoreErrorTests {
|
||||
@Test func errorDescription_returnsUserFriendlyMessage() {
|
||||
#expect(StoreError.productNotFound.localizedDescription.contains("not found"))
|
||||
#expect(StoreError.purchaseFailed.localizedDescription.contains("failed"))
|
||||
#expect(StoreError.verificationFailed.localizedDescription.contains("verify"))
|
||||
}
|
||||
}
|
||||
36
SportsTimeTests/Store/StoreManagerTests.swift
Normal file
36
SportsTimeTests/Store/StoreManagerTests.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// StoreManagerTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
|
||||
import Testing
|
||||
import StoreKit
|
||||
@testable import SportsTime
|
||||
|
||||
struct StoreManagerTests {
|
||||
@Test func shared_returnsSingletonInstance() async {
|
||||
let instance1 = await StoreManager.shared
|
||||
let instance2 = await StoreManager.shared
|
||||
#expect(instance1 === instance2)
|
||||
}
|
||||
|
||||
@Test func isPro_isAccessible() async {
|
||||
let manager = await StoreManager.shared
|
||||
// Fresh state should not be Pro
|
||||
// Note: In real tests, we'd reset state first
|
||||
let _ = await manager.isPro // Just verify it's accessible
|
||||
#expect(true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func proProductIDs_containsExpectedProducts() {
|
||||
#expect(StoreManager.proProductIDs.contains("com.sportstime.pro.monthly"))
|
||||
#expect(StoreManager.proProductIDs.contains("com.sportstime.pro.annual"))
|
||||
#expect(StoreManager.proProductIDs.count == 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func freeTripLimit_returnsOne() {
|
||||
#expect(StoreManager.freeTripLimit == 1)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ final class TripWizardViewModelTests: XCTestCase {
|
||||
viewModel.selectedRegions = [.east, .central]
|
||||
viewModel.hasSetRoutePreference = true
|
||||
viewModel.hasSetRepeatCities = true
|
||||
viewModel.mustStopLocations = [LocationInput(name: "Test", coordinates: nil)]
|
||||
viewModel.mustStopLocations = [LocationInput(name: "Test", coordinate: nil)]
|
||||
|
||||
// Change planning mode
|
||||
viewModel.planningMode = .locations
|
||||
@@ -176,7 +176,7 @@ final class TripWizardViewModelTests: XCTestCase {
|
||||
|
||||
func test_mustStopLocations_canBeAdded() {
|
||||
let viewModel = TripWizardViewModel()
|
||||
let location = LocationInput(name: "Chicago, IL", coordinates: nil)
|
||||
let location = LocationInput(name: "Chicago, IL", coordinate: nil)
|
||||
|
||||
viewModel.mustStopLocations.append(location)
|
||||
|
||||
|
||||
@@ -4,29 +4,36 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
@testable import SportsTime
|
||||
|
||||
struct TripOptionsGroupingTests {
|
||||
|
||||
// Helper to create mock ItineraryStop
|
||||
private func makeStop(city: String, games: [String] = []) -> ItineraryStop {
|
||||
ItineraryStop(
|
||||
city: city,
|
||||
state: "XX",
|
||||
coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0),
|
||||
games: games,
|
||||
arrivalDate: Date(),
|
||||
departureDate: Date(),
|
||||
location: LocationInput(name: city, coordinate: nil),
|
||||
firstGameStart: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to create mock ItineraryOption
|
||||
private func makeOption(stops: [(city: String, games: [String])], totalMiles: Double = 500) -> ItineraryOption {
|
||||
let tripStops = stops.map { stopData in
|
||||
TripStop(
|
||||
city: stopData.city,
|
||||
state: "XX",
|
||||
coordinate: .init(latitude: 0, longitude: 0),
|
||||
games: stopData.games,
|
||||
arrivalDate: Date(),
|
||||
departureDate: Date(),
|
||||
travelFromPrevious: nil
|
||||
)
|
||||
}
|
||||
let itineraryStops = stops.map { makeStop(city: $0.city, games: $0.games) }
|
||||
return ItineraryOption(
|
||||
id: UUID().uuidString,
|
||||
stops: tripStops,
|
||||
totalDistanceMiles: totalMiles,
|
||||
rank: 1,
|
||||
stops: itineraryStops,
|
||||
travelSegments: [],
|
||||
totalDrivingHours: totalMiles / 60,
|
||||
score: 1.0
|
||||
totalDistanceMiles: totalMiles,
|
||||
geographicRationale: "Test"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user