diff --git a/CLAUDE.md b/CLAUDE.md index ca7851d..652ca07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Build**: `swift build` - **Test**: `swift test` - **Single test**: Use test explorer in Xcode or target specific test methods in Swift Testing -- **Swift version**: `swift --version` (requires Swift 6.1+) +- **Swift version**: `swift --version` (requires Swift 6.2+) ### Xcode Development - Open `InAppKit.xcodeproj` or `Workspace.xcworkspace` for full IDE experience @@ -16,64 +16,127 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Architecture Overview -InAppKit is a SwiftUI library that simplifies in-app purchases through a declarative API. The architecture follows these key patterns: +InAppKit is a SwiftUI library that simplifies in-app purchases through a declarative API. The architecture follows **Domain-Driven Design** with clear separation between domain logic and infrastructure. -### Core Components +### Domain Layer (`Sources/InAppKit/Core/Domain/`) -1. **InAppKit (Singleton)** (`Sources/InAppKit/Core/InAppKit.swift`) - - `@MainActor @Observable` singleton managing all purchase state - - Handles StoreKit integration, transaction validation, and feature access - - Maps features to products and maintains purchase entitlements +Pure business logic with 100% test coverage. No StoreKit dependencies. -2. **Product Configuration System** (`Sources/InAppKit/Configuration/`) - - `ProductConfig` - Type-safe product definitions with features - - `StoreKitConfiguration` - Fluent API for app setup - - `PaywallContext` - Context object for paywall presentations +| Model | Purpose | +|-------|---------| +| `ProductDefinition` | Define products to sell with features and marketing | +| `DiscountRule` | Configure relative discounts between products | +| `PaywallContext` | Context data for paywall presentation | +| `PurchaseState` | Immutable purchase state (what user owns) | +| `FeatureRegistry` | Feature-to-product mappings | +| `AccessControl` | Pure functions for access control decisions | +| `MarketingRegistry` | Product marketing information storage | +| `Store` | Protocol for store operations (@Mockable) | -3. **Feature System** (`Sources/InAppKit/Core/Feature.swift`) - - `AppFeature` protocol for type-safe feature definitions - - Features map to product IDs through configuration - - Supports both enum-based and string-based feature definitions +### Infrastructure Layer (`Sources/InAppKit/Infrastructure/`) -### Key Patterns +StoreKit integration. Implements domain protocols. -**Fluent Configuration API**: The library uses method chaining for setup: +| Class | Purpose | +|-------|---------| +| `AppStore` | Real Store implementation using StoreKit | +| `StoreKitProvider` | Protocol wrapping StoreKit static methods (@Mockable) | +| `DefaultStoreKitProvider` | Real StoreKit API calls | + +### Core Layer (`Sources/InAppKit/Core/`) + +| Class | Purpose | +|-------|---------| +| `InAppKit` | Main coordinator, delegates to domain models | +| `Feature` | AppFeature protocol for type-safe features | + +### UI Layer (`Sources/InAppKit/UI/`, `Sources/InAppKit/Modifiers/`) + +SwiftUI views and modifiers for purchase integration. + +## Key Design Patterns + +### 1. Domain-Driven Design +- Pure domain models with no external dependencies +- Immutable value types with functional updates +- Business logic encapsulated in domain layer + +### 2. Dependency Injection with Mockable +```swift +// Domain protocol +@Mockable +public protocol Store: Sendable { + func products(for ids: Set) async throws -> [Product] + func purchase(_ product: Product) async throws -> PurchaseOutcome + func purchases() async throws -> Set +} + +// Production uses real AppStore +let inAppKit = InAppKit.shared // uses AppStore internally + +// Testing uses MockStore (auto-generated) +let mockStore = MockStore() +given(mockStore).purchases().willReturn(["com.app.pro"]) +let inAppKit = InAppKit.configure(with: mockStore) +``` + +### 3. Fluent Configuration API ```swift ContentView() .withPurchases(products: [ Product("com.app.pro", features: AppFeature.allCases) + .withBadge("Best Value") + .withRelativeDiscount(comparedTo: "monthly") ]) .withPaywall { context in PaywallView(products: context.availableProducts) } ``` -**View Modifiers**: Main integration points are SwiftUI view modifiers: -- `.withPurchases()` - Initializes purchase system -- `.requiresPurchase()` - Gates content behind purchases +### 4. View Modifiers +- `.withPurchases()` - Initialize purchase system +- `.requiresPurchase()` - Gate content behind purchases - `.withPaywall()` - Custom paywall presentation -**Type Safety**: Features are defined as enums conforming to `AppFeature` protocol for compile-time safety. +## Testing Approach -### UI Architecture +### Domain Tests (100% coverage) +Pure domain models are fully testable without mocks: +```swift +@Test func `user with correct purchase has access to feature`() { + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let registry = FeatureRegistry().withFeature("sync", productIds: ["com.app.pro"]) -- **Component-based**: Reusable UI components in `Sources/InAppKit/UI/Components/` -- **Modifier-driven**: Purchase gating through view modifiers in `Sources/InAppKit/Modifiers/` -- **Localization**: Full i18n support with fallback strings in `Sources/InAppKit/Extensions/Localization.swift` + let hasAccess = AccessControl.hasAccess(to: "sync", purchaseState: purchaseState, featureRegistry: registry) -### StoreKit Integration + #expect(hasAccess) +} +``` -- **Observable Pattern**: Uses Swift's `@Observable` for state management -- **Transaction Handling**: Automatic verification and entitlement updates -- **Background Listening**: Persistent transaction listener for receipt updates +### Infrastructure Tests (with Mockable) +```swift +@Test func `loadProducts calls store products`() async { + let mockStore = MockStore() + given(mockStore).products(for: .any).willReturn([]) -## Testing Approach + let inAppKit = InAppKit.configure(with: mockStore) + await inAppKit.loadProducts(productIds: ["com.app.pro"]) -Uses Swift Testing framework with `@testable import InAppKit`: -- Feature configuration testing -- Product mapping validation -- Fluent API chain testing -- Mock purchase simulation in DEBUG builds + await verify(mockStore).products(for: .value(Set(["com.app.pro"]))).called(.once) +} +``` -The test suite focuses on configuration validation and API usability rather than StoreKit integration (which requires App Store Connect setup). +### Test Organization +``` +Tests/InAppKitTests/ +├── Domain/ ← Pure domain model tests +│ ├── PurchaseStateTests.swift +│ ├── FeatureRegistryTests.swift +│ ├── AccessControlTests.swift +│ └── MarketingRegistryTests.swift +├── Infrastructure/ ← Tests with MockStore/MockStoreKitProvider +│ ├── StoreTests.swift +│ └── AppStoreTests.swift +└── InAppKitTests.swift ← Integration tests +``` ## Documentation Structure @@ -91,4 +154,4 @@ When helping users with InAppKit: 2. Point users to relevant documentation sections for deeper learning 3. Use the API reference for accurate method signatures and usage examples 4. Consult monetization patterns when discussing business strategy -5. Reference localization guide for internationalization questions \ No newline at end of file +5. Reference localization guide for internationalization questions diff --git a/Package.swift b/Package.swift index c069288..9a04ba2 100644 --- a/Package.swift +++ b/Package.swift @@ -12,19 +12,32 @@ let package = Package( .tvOS(.v17) ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "InAppKit", targets: ["InAppKit"]), ], + dependencies: [ + .package(url: "https://github.com/Kolos65/Mockable.git", from: "0.5.0"), + ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( - name: "InAppKit"), + name: "InAppKit", + dependencies: [ + .product(name: "Mockable", package: "Mockable"), + ], + swiftSettings: [ + .define("MOCKING", .when(configuration: .debug)), + ] + ), .testTarget( name: "InAppKitTests", - dependencies: ["InAppKit"] + dependencies: [ + "InAppKit", + .product(name: "Mockable", package: "Mockable"), + ], + swiftSettings: [ + .define("MOCKING", .when(configuration: .debug)), + ] ), ] ) diff --git a/Sources/InAppKit/Configuration/ProductConfiguration.swift b/Sources/InAppKit/Configuration/ProductConfiguration.swift deleted file mode 100644 index 4721223..0000000 --- a/Sources/InAppKit/Configuration/ProductConfiguration.swift +++ /dev/null @@ -1,285 +0,0 @@ -// -// ProductConfiguration.swift -// InAppKit -// -// Product configuration types and convenience functions -// - -import Foundation -import SwiftUI -import StoreKit -import OSLog - -// MARK: - Relative Discount Configuration - -/// Configuration for automatic relative discount calculation -public struct RelativeDiscountConfig: Sendable { - public let baseProductId: String - public let style: DiscountStyle - public let color: Color? - - public init(baseProductId: String, style: DiscountStyle = .percentage, color: Color? = nil) { - self.baseProductId = baseProductId - self.style = style - self.color = color - } - - public enum DiscountStyle: Sendable { - case percentage // "31% off" - case amount // "Save $44" - case freeTime // "2 months free" - } -} - -// MARK: - Product Configuration Support - -public protocol AnyProductConfig { - var id: String { get } - var badge: String? { get } - var badgeColor: Color? { get } - var marketingFeatures: [String]? { get } - var promoText: String? { get } - var relativeDiscountConfig: RelativeDiscountConfig? { get } - func toInternal() -> InternalProductConfig -} - -public struct ProductConfig: AnyProductConfig { - public let id: String - public let features: [T] - public let badge: String? - public let badgeColor: Color? - public let marketingFeatures: [String]? - public let promoText: String? - public let relativeDiscountConfig: RelativeDiscountConfig? - - public init( - _ id: String, - features: [T], - badge: String? = nil, - badgeColor: Color? = nil, - marketingFeatures: [String]? = nil, - promoText: String? = nil, - relativeDiscountConfig: RelativeDiscountConfig? = nil - ) { - self.id = id - self.features = features - self.badge = badge - self.badgeColor = badgeColor - self.marketingFeatures = marketingFeatures - self.promoText = promoText - self.relativeDiscountConfig = relativeDiscountConfig - } - - public func toInternal() -> InternalProductConfig { - #if DEBUG - if let config = relativeDiscountConfig { - Logger.statistics.debug("🟢 toInternal() preserving relativeDiscountConfig for \(self.id): \(config.baseProductId)") - } - #endif - - return InternalProductConfig( - id: id, - features: features.map { AnyHashable($0) }, - badge: badge, - badgeColor: badgeColor, - marketingFeatures: marketingFeatures, - promoText: promoText, - relativeDiscountConfig: relativeDiscountConfig - ) - } -} - -// Convenience for AnyHashable -public struct InternalProductConfig: @unchecked Sendable { - public let id: String - public let features: [AnyHashable] - public let badge: String? - public let badgeColor: Color? - public let marketingFeatures: [String]? - public let promoText: String? - public let relativeDiscountConfig: RelativeDiscountConfig? - - public init( - id: String, - features: [AnyHashable], - badge: String? = nil, - badgeColor: Color? = nil, - marketingFeatures: [String]? = nil, - promoText: String? = nil, - relativeDiscountConfig: RelativeDiscountConfig? = nil - ) { - self.id = id - self.features = features - self.badge = badge - self.badgeColor = badgeColor - self.marketingFeatures = marketingFeatures - self.promoText = promoText - self.relativeDiscountConfig = relativeDiscountConfig - } -} - -// MARK: - Convenience Functions for Product Creation - -// Simple product without features (most common case) -public func Product(_ id: String) -> ProductConfig { - ProductConfig(id, features: []) -} - -// New fluent API convenience functions -public func Product(_ id: String, features: [T]) -> ProductConfig { - ProductConfig(id, features: features) -} - -// Support for .allCases with features: label (consistent API) -public func Product(_ id: String, features allCases: T.AllCases) -> ProductConfig { - ProductConfig(id, features: Array(allCases)) -} - -// MARK: - Fluent API Extensions for Marketing - -public extension ProductConfig { - /// Add a promotional badge to the product - func withBadge(_ badge: String) -> ProductConfig { - ProductConfig( - id, - features: features, - badge: badge, - badgeColor: badgeColor, - marketingFeatures: marketingFeatures, - promoText: promoText, - relativeDiscountConfig: relativeDiscountConfig - ) - } - - /// Add a promotional badge with custom color - func withBadge(_ badge: String, color: Color) -> ProductConfig { - ProductConfig( - id, - features: features, - badge: badge, - badgeColor: color, - marketingFeatures: marketingFeatures, - promoText: promoText, - relativeDiscountConfig: relativeDiscountConfig - ) - } - - /// Add marketing features (shown as bullet points in UI) - func withMarketingFeatures(_ features: [String]) -> ProductConfig { - ProductConfig( - id, - features: self.features, - badge: badge, - badgeColor: badgeColor, - marketingFeatures: features, - promoText: promoText, - relativeDiscountConfig: relativeDiscountConfig - ) - } - - /// Add promotional text to highlight the product - /// - Parameter text: Custom promotional message (e.g., "Save $44", "Limited Time", "Best Value!") - /// - Returns: Updated product configuration - /// - /// Example: - /// ```swift - /// Product("yearly", features: features) - /// .withPromoText("Save $44") - /// ``` - func withPromoText(_ text: String) -> ProductConfig { - ProductConfig( - id, - features: features, - badge: badge, - badgeColor: badgeColor, - marketingFeatures: marketingFeatures, - promoText: text, - relativeDiscountConfig: relativeDiscountConfig - ) - } - - /// Add automatic relative discount calculation - /// - Parameters: - /// - baseProductId: The product ID to compare against (e.g., monthly when this is yearly) - /// - style: How to display the discount (percentage, amount, or free time) - /// - color: Custom color for the discount text (defaults to orange if not specified) - /// - Returns: Updated product configuration - /// - /// Example: - /// ```swift - /// Product("yearly", features: features) - /// .withRelativeDiscount(comparedTo: "monthly", style: .percentage, color: .green) - /// // Displays: "Save 31%" in green (calculated automatically) - /// ``` - func withRelativeDiscount(comparedTo baseProductId: String, style: RelativeDiscountConfig.DiscountStyle = .percentage, color: Color? = nil) -> ProductConfig { - let config = RelativeDiscountConfig(baseProductId: baseProductId, style: style, color: color) - - #if DEBUG - Logger.statistics.debug("🔵 Creating ProductConfig with relativeDiscount: \(self.id) -> \(baseProductId)") - #endif - - return ProductConfig( - id, - features: features, - badge: badge, - badgeColor: badgeColor, - marketingFeatures: marketingFeatures, - promoText: promoText, - relativeDiscountConfig: config - ) - } -} - -// MARK: - PaywallContext - -/// Context for product-based paywalls with marketing information -public struct PaywallContext { - public let triggeredBy: String? // What action triggered the paywall - public let availableProducts: [StoreKit.Product] // Products that can be purchased - public let recommendedProduct: StoreKit.Product? // Best product to recommend - - public init(triggeredBy: String? = nil, availableProducts: [StoreKit.Product] = [], recommendedProduct: StoreKit.Product? = nil) { - self.triggeredBy = triggeredBy - self.availableProducts = availableProducts - self.recommendedProduct = recommendedProduct ?? availableProducts.first - } - - // MARK: - Marketing Information Helpers - - /// Get marketing badge for a product - @MainActor - public func badge(for product: StoreKit.Product) -> String? { - return InAppKit.shared.badge(for: product.id) - } - - /// Get marketing features for a product - @MainActor - public func marketingFeatures(for product: StoreKit.Product) -> [String]? { - return InAppKit.shared.marketingFeatures(for: product.id) - } - - /// Get promotional text for a product - @MainActor - public func promoText(for product: StoreKit.Product) -> String? { - return InAppKit.shared.promoText(for: product.id) - } - - /// Get all marketing information for a product - @MainActor - public func marketingInfo(for product: StoreKit.Product) -> (badge: String?, features: [String]?, promoText: String?) { - return ( - badge: badge(for: product), - features: marketingFeatures(for: product), - promoText: promoText(for: product) - ) - } - - /// Get products with their marketing information - @MainActor - public var productsWithMarketing: [(product: StoreKit.Product, badge: String?, features: [String]?, promoText: String?)] { - return availableProducts.map { product in - let info = marketingInfo(for: product) - return (product: product, badge: info.badge, features: info.features, promoText: info.promoText) - } - } -} diff --git a/Sources/InAppKit/Configuration/StoreKitConfiguration.swift b/Sources/InAppKit/Configuration/StoreKitConfiguration.swift deleted file mode 100644 index 0c3a2bc..0000000 --- a/Sources/InAppKit/Configuration/StoreKitConfiguration.swift +++ /dev/null @@ -1,299 +0,0 @@ -// -// StoreKitConfiguration.swift -// InAppKit -// -// Fluent API for StoreKit configuration with chainable methods -// - -import Foundation -import SwiftUI -import StoreKit - -// MARK: - Fluent Configuration API - -@MainActor -public class StoreKitConfiguration { - internal var productConfigs: [InternalProductConfig] = [] - internal var paywallBuilder: ((PaywallContext) -> AnyView)? - internal var paywallHeaderBuilder: (() -> AnyView)? - internal var paywallFeaturesBuilder: (() -> AnyView)? - internal var termsBuilder: (() -> AnyView)? - internal var privacyBuilder: (() -> AnyView)? - internal var termsURL: URL? - internal var privacyURL: URL? - - public init() {} - - // MARK: - Fluent API Methods - - /// Configure purchases with minimal setup - public func withPurchases(_ productId: String) -> StoreKitConfiguration { - let config = InternalProductConfig(id: productId, features: []) - productConfigs.append(config) - return self - } - - /// Configure purchases with multiple product IDs (variadic) - public func withPurchases(_ productIds: String...) -> StoreKitConfiguration { - let configs = productIds.map { InternalProductConfig(id: $0, features: []) } - productConfigs.append(contentsOf: configs) - return self - } - - /// Configure purchases with features (supports mixed types) - public func withPurchases(products: [ProductConfig]) -> StoreKitConfiguration { - productConfigs.append(contentsOf: products.map { $0.toInternal() }) - return self - } - - /// Configure purchases with simple products (no features) - public func withPurchases(products: [ProductConfig]) -> StoreKitConfiguration { - productConfigs.append(contentsOf: products.map { $0.toInternal() }) - return self - } - - /// Configure purchases with mixed product types (generic protocol approach) - public func withPurchases(products: [AnyProductConfig]) -> StoreKitConfiguration { - productConfigs.append(contentsOf: products.map { $0.toInternal() }) - return self - } - - /// Configure custom paywall - public func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> Content) -> StoreKitConfiguration { - paywallBuilder = { context in AnyView(builder(context)) } - return self - } - - /// Configure terms view with custom SwiftUI content - public func withTerms(@ViewBuilder _ builder: @escaping () -> Content) -> StoreKitConfiguration { - termsBuilder = { AnyView(builder()) } - return self - } - - /// Configure terms with a URL to display - /// - Parameter url: The URL to open when terms is tapped - /// - Returns: The configuration instance for chaining - public func withTerms(url: URL) -> StoreKitConfiguration { - termsURL = url - return self - } - - /// Configure privacy view with custom SwiftUI content - public func withPrivacy(@ViewBuilder _ builder: @escaping () -> Content) -> StoreKitConfiguration { - privacyBuilder = { AnyView(builder()) } - return self - } - - /// Configure privacy with a URL to display - /// - Parameter url: The URL to open when privacy is tapped - /// - Returns: The configuration instance for chaining - public func withPrivacy(url: URL) -> StoreKitConfiguration { - privacyURL = url - return self - } - - /// Configure paywall header section - public func withPaywallHeader(@ViewBuilder _ builder: @escaping () -> Content) -> StoreKitConfiguration { - paywallHeaderBuilder = { AnyView(builder()) } - return self - } - - /// Configure paywall features section - public func withPaywallFeatures(@ViewBuilder _ builder: @escaping () -> Content) -> StoreKitConfiguration { - paywallFeaturesBuilder = { AnyView(builder()) } - return self - } - - // MARK: - Internal Setup - - internal func setup() async { - // Use the existing InAppKit.initialize method which handles both features and marketing info - await InAppKit.shared.initialize(with: productConfigs) - } -} - -// MARK: - Environment Keys - -private struct PaywallBuilderKey: EnvironmentKey { - nonisolated(unsafe) static let defaultValue: ((PaywallContext) -> AnyView)? = nil -} - -private struct TermsBuilderKey: EnvironmentKey { - nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil -} - -private struct PrivacyBuilderKey: EnvironmentKey { - nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil -} - -private struct PaywallHeaderBuilderKey: EnvironmentKey { - nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil -} - -private struct PaywallFeaturesBuilderKey: EnvironmentKey { - nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil -} - -private struct TermsURLKey: EnvironmentKey { - static let defaultValue: URL? = nil -} - -private struct PrivacyURLKey: EnvironmentKey { - static let defaultValue: URL? = nil -} - -public extension EnvironmentValues { - var paywallBuilder: ((PaywallContext) -> AnyView)? { - get { self[PaywallBuilderKey.self] } - set { self[PaywallBuilderKey.self] = newValue } - } - - var termsBuilder: (() -> AnyView)? { - get { self[TermsBuilderKey.self] } - set { self[TermsBuilderKey.self] = newValue } - } - - var privacyBuilder: (() -> AnyView)? { - get { self[PrivacyBuilderKey.self] } - set { self[PrivacyBuilderKey.self] = newValue } - } - - var paywallHeaderBuilder: (() -> AnyView)? { - get { self[PaywallHeaderBuilderKey.self] } - set { self[PaywallHeaderBuilderKey.self] = newValue } - } - - var paywallFeaturesBuilder: (() -> AnyView)? { - get { self[PaywallFeaturesBuilderKey.self] } - set { self[PaywallFeaturesBuilderKey.self] = newValue } - } - - var termsURL: URL? { - get { self[TermsURLKey.self] } - set { self[TermsURLKey.self] = newValue } - } - - var privacyURL: URL? { - get { self[PrivacyURLKey.self] } - set { self[PrivacyURLKey.self] = newValue } - } -} - -// MARK: - Chainable View Wrapper - -@MainActor -public struct ChainableStoreKitView: View { - let content: Content - let config: StoreKitConfiguration - - internal init(content: Content, config: StoreKitConfiguration) { - self.content = content - self.config = config - } - - public var body: some View { - content.modifier(InAppKitModifier(config: config)) - } - - /// Add paywall configuration to the chain - public func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> PaywallContent) -> ChainableStoreKitView { - let newConfig = config.withPaywall(builder) - return ChainableStoreKitView(content: content, config: newConfig) - } - - /// Add terms configuration to the chain with custom SwiftUI content - public func withTerms(@ViewBuilder _ builder: @escaping () -> TermsContent) -> ChainableStoreKitView { - let newConfig = config.withTerms(builder) - return ChainableStoreKitView(content: content, config: newConfig) - } - - /// Add terms configuration to the chain with a URL - public func withTerms(url: URL) -> ChainableStoreKitView { - let newConfig = config.withTerms(url: url) - return ChainableStoreKitView(content: content, config: newConfig) - } - - /// Add privacy configuration to the chain with custom SwiftUI content - public func withPrivacy(@ViewBuilder _ builder: @escaping () -> PrivacyContent) -> ChainableStoreKitView { - let newConfig = config.withPrivacy(builder) - return ChainableStoreKitView(content: content, config: newConfig) - } - - /// Add privacy configuration to the chain with a URL - public func withPrivacy(url: URL) -> ChainableStoreKitView { - let newConfig = config.withPrivacy(url: url) - return ChainableStoreKitView(content: content, config: newConfig) - } - - /// Add paywall header configuration to the chain - public func withPaywallHeader(@ViewBuilder _ builder: @escaping () -> HeaderContent) -> ChainableStoreKitView { - let newConfig = config.withPaywallHeader(builder) - return ChainableStoreKitView(content: content, config: newConfig) - } - - /// Add paywall features configuration to the chain - public func withPaywallFeatures(@ViewBuilder _ builder: @escaping () -> FeaturesContent) -> ChainableStoreKitView { - let newConfig = config.withPaywallFeatures(builder) - return ChainableStoreKitView(content: content, config: newConfig) - } -} - -// MARK: - InAppKit Modifier - -private struct InAppKitModifier: ViewModifier { - let config: StoreKitConfiguration - - func body(content: Content) -> some View { - content - .environment(\.paywallBuilder, config.paywallBuilder) - .environment(\.paywallHeaderBuilder, config.paywallHeaderBuilder) - .environment(\.paywallFeaturesBuilder, config.paywallFeaturesBuilder) - .environment(\.termsBuilder, config.termsBuilder) - .environment(\.privacyBuilder, config.privacyBuilder) - .environment(\.termsURL, config.termsURL) - .environment(\.privacyURL, config.privacyURL) - .task { - await config.setup() - } - } -} - -// MARK: - Chainable View Extensions - -public extension View { - /// Start fluent API chain with products - func withPurchases(products: [ProductConfig]) -> ChainableStoreKitView { - let config = StoreKitConfiguration() - .withPurchases(products: products) - return ChainableStoreKitView(content: self, config: config) - } - - /// Start fluent API chain with simple products (no features) - func withPurchases(products: [ProductConfig]) -> ChainableStoreKitView { - let config = StoreKitConfiguration() - .withPurchases(products: products) - return ChainableStoreKitView(content: self, config: config) - } - - /// Start fluent API chain with mixed product types - func withPurchases(products: [AnyProductConfig]) -> ChainableStoreKitView { - let config = StoreKitConfiguration() - .withPurchases(products: products) - return ChainableStoreKitView(content: self, config: config) - } - - /// Start fluent API chain with single product - func withPurchases(_ productId: String) -> ChainableStoreKitView { - let config = StoreKitConfiguration() - .withPurchases(productId) - return ChainableStoreKitView(content: self, config: config) - } - - /// Start fluent API chain with multiple product IDs (variadic) - func withPurchases(_ productIds: String...) -> ChainableStoreKitView { - let config = StoreKitConfiguration() - let configs = productIds.map { InternalProductConfig(id: $0, features: []) } - config.productConfigs.append(contentsOf: configs) - return ChainableStoreKitView(content: self, config: config) - } -} diff --git a/Sources/InAppKit/Core/Domain/AccessControl.swift b/Sources/InAppKit/Core/Domain/AccessControl.swift new file mode 100644 index 0000000..3903016 --- /dev/null +++ b/Sources/InAppKit/Core/Domain/AccessControl.swift @@ -0,0 +1,95 @@ +// +// AccessControl.swift +// InAppKit +// +// Pure domain logic for access control decisions. +// No StoreKit dependency - 100% testable. +// + +import Foundation + +/// Access control logic - pure functions operating on domain models +public enum AccessControl { + + /// Check if user has access to a feature + /// - Parameters: + /// - feature: The feature to check access for + /// - purchaseState: Current purchase state + /// - featureRegistry: Feature to product mappings + /// - Returns: true if user has access + public static func hasAccess( + to feature: AnyHashable, + purchaseState: PurchaseState, + featureRegistry: FeatureRegistry + ) -> Bool { + let requiredProducts = featureRegistry.productIds(for: feature) + + // Fallback: if no products mapped to this feature, check any purchase + if requiredProducts.isEmpty { + return purchaseState.hasAnyPurchase + } + + // Check if user owns any of the required products + return requiredProducts.contains { productId in + purchaseState.isPurchased(productId) + } + } + + /// Check if user has access to an AppFeature + public static func hasAccess( + to feature: T, + purchaseState: PurchaseState, + featureRegistry: FeatureRegistry + ) -> Bool { + hasAccess( + to: AnyHashable(feature.rawValue), + purchaseState: purchaseState, + featureRegistry: featureRegistry + ) + } + + /// Check access for multiple features at once + public static func accessStatus( + for features: [AnyHashable], + purchaseState: PurchaseState, + featureRegistry: FeatureRegistry + ) -> [AnyHashable: Bool] { + var result: [AnyHashable: Bool] = [:] + for feature in features { + result[feature] = hasAccess( + to: feature, + purchaseState: purchaseState, + featureRegistry: featureRegistry + ) + } + return result + } + + /// Get all features user has access to + public static func accessibleFeatures( + purchaseState: PurchaseState, + featureRegistry: FeatureRegistry + ) -> Set { + featureRegistry.allFeatures.filter { feature in + hasAccess( + to: feature, + purchaseState: purchaseState, + featureRegistry: featureRegistry + ) + } + } + + /// Get features user is missing (doesn't have access to) + public static func missingFeatures( + purchaseState: PurchaseState, + featureRegistry: FeatureRegistry + ) -> Set { + featureRegistry.allFeatures.filter { feature in + !hasAccess( + to: feature, + purchaseState: purchaseState, + featureRegistry: featureRegistry + ) + } + } +} diff --git a/Sources/InAppKit/Core/Domain/FeatureRegistry.swift b/Sources/InAppKit/Core/Domain/FeatureRegistry.swift new file mode 100644 index 0000000..4a712c0 --- /dev/null +++ b/Sources/InAppKit/Core/Domain/FeatureRegistry.swift @@ -0,0 +1,94 @@ +// +// FeatureRegistry.swift +// InAppKit +// +// Pure domain model for feature-to-product mappings. +// No StoreKit dependency - 100% testable. +// + +import Foundation + +/// Registry for mapping features to product IDs - pure value type +public struct FeatureRegistry: Equatable { + private var featureToProducts: [AnyHashable: Set] + private var productToFeatures: [String: Set] + + public init() { + self.featureToProducts = [:] + self.productToFeatures = [:] + } + + // MARK: - Queries + + /// Check if a feature is registered + public func isRegistered(_ feature: AnyHashable) -> Bool { + featureToProducts[feature] != nil + } + + /// Get product IDs that provide a feature + public func productIds(for feature: AnyHashable) -> Set { + featureToProducts[feature] ?? [] + } + + /// Get features provided by a product + public func features(for productId: String) -> Set { + productToFeatures[productId] ?? [] + } + + /// Get all registered features + public var allFeatures: Set { + Set(featureToProducts.keys) + } + + /// Get all registered product IDs + public var allProductIds: Set { + Set(productToFeatures.keys) + } + + // MARK: - Commands (return new state - immutable) + + /// Register a feature with product IDs + public func withFeature(_ feature: AnyHashable, productIds: [String]) -> FeatureRegistry { + var newRegistry = self + + // Update feature -> products mapping + let existingProducts = newRegistry.featureToProducts[feature] ?? [] + newRegistry.featureToProducts[feature] = existingProducts.union(productIds) + + // Update product -> features mapping (bidirectional) + for productId in productIds { + let existingFeatures = newRegistry.productToFeatures[productId] ?? [] + newRegistry.productToFeatures[productId] = existingFeatures.union([feature]) + } + + return newRegistry + } + + /// Register multiple features at once + public func withFeatures(_ mappings: [(feature: AnyHashable, productIds: [String])]) -> FeatureRegistry { + var registry = self + for mapping in mappings { + registry = registry.withFeature(mapping.feature, productIds: mapping.productIds) + } + return registry + } +} + +// MARK: - AppFeature Convenience + +public extension FeatureRegistry { + /// Register an AppFeature with product IDs + func withFeature(_ feature: T, productIds: [String]) -> FeatureRegistry { + withFeature(AnyHashable(feature.rawValue), productIds: productIds) + } + + /// Check if an AppFeature is registered + func isRegistered(_ feature: T) -> Bool { + isRegistered(AnyHashable(feature.rawValue)) + } + + /// Get product IDs for an AppFeature + func productIds(for feature: T) -> Set { + productIds(for: AnyHashable(feature.rawValue)) + } +} diff --git a/Sources/InAppKit/Core/Domain/MarketingRegistry.swift b/Sources/InAppKit/Core/Domain/MarketingRegistry.swift new file mode 100644 index 0000000..defa867 --- /dev/null +++ b/Sources/InAppKit/Core/Domain/MarketingRegistry.swift @@ -0,0 +1,142 @@ +// +// MarketingRegistry.swift +// InAppKit +// +// Pure domain model for product marketing information. +// No StoreKit dependency - 100% testable. +// + +import SwiftUI + +/// Marketing information for a product - pure value type +public struct ProductMarketing: Sendable { + public let badge: String? + public let badgeColor: Color? + public let features: [String]? + public let promoText: String? + public let discountRule: DiscountRule? + + public init( + badge: String? = nil, + badgeColor: Color? = nil, + features: [String]? = nil, + promoText: String? = nil, + discountRule: DiscountRule? = nil + ) { + self.badge = badge + self.badgeColor = badgeColor + self.features = features + self.promoText = promoText + self.discountRule = discountRule + } + + // MARK: - Domain Behavior + + /// Whether this product has any marketing info configured + public var hasMarketing: Bool { + badge != nil || features != nil || promoText != nil + } + + /// Whether this product has a promotional badge + public var hasBadge: Bool { + badge != nil + } + + /// Whether this product has discount rule configured + public var hasDiscountRule: Bool { + discountRule != nil + } +} + +/// Registry for product marketing information - pure value type +public struct MarketingRegistry { + private var marketingInfo: [String: ProductMarketing] + + public init() { + self.marketingInfo = [:] + } + + // MARK: - Queries + + /// Get marketing info for a product + public func marketing(for productId: String) -> ProductMarketing? { + marketingInfo[productId] + } + + /// Get badge for a product + public func badge(for productId: String) -> String? { + marketingInfo[productId]?.badge + } + + /// Get badge color for a product + public func badgeColor(for productId: String) -> Color? { + marketingInfo[productId]?.badgeColor + } + + /// Get marketing features for a product + public func features(for productId: String) -> [String]? { + marketingInfo[productId]?.features + } + + /// Get promo text for a product + public func promoText(for productId: String) -> String? { + marketingInfo[productId]?.promoText + } + + /// Get discount rule for a product + public func discountRule(for productId: String) -> DiscountRule? { + marketingInfo[productId]?.discountRule + } + + /// Get all product IDs with marketing info + public var allProductIds: Set { + Set(marketingInfo.keys) + } + + /// Get all products with badges + public var productsWithBadges: [String] { + marketingInfo.filter { $0.value.hasBadge }.map { $0.key } + } + + // MARK: - Commands (return new state - immutable) + + /// Register marketing info for a product + public func withMarketing(_ productId: String, marketing: ProductMarketing) -> MarketingRegistry { + var newRegistry = self + newRegistry.marketingInfo[productId] = marketing + return newRegistry + } + + /// Register marketing info from ProductDefinition + public func withMarketing(from product: ProductDefinition) -> MarketingRegistry { + let marketing = ProductMarketing( + badge: product.badge, + badgeColor: product.badgeColor, + features: product.marketingFeatures, + promoText: product.promoText, + discountRule: product.discountRule + ) + return withMarketing(product.id, marketing: marketing) + } + + /// Register marketing info from multiple ProductDefinitions + public func withMarketing(from products: [ProductDefinition]) -> MarketingRegistry { + var registry = self + for product in products { + registry = registry.withMarketing(from: product) + } + return registry + } + + /// Remove marketing info for a product + public func withoutMarketing(for productId: String) -> MarketingRegistry { + var newRegistry = self + newRegistry.marketingInfo.removeValue(forKey: productId) + return newRegistry + } + + /// Clear all marketing info + public func cleared() -> MarketingRegistry { + MarketingRegistry() + } +} diff --git a/Sources/InAppKit/Core/Domain/PaywallContext.swift b/Sources/InAppKit/Core/Domain/PaywallContext.swift new file mode 100644 index 0000000..835d00f --- /dev/null +++ b/Sources/InAppKit/Core/Domain/PaywallContext.swift @@ -0,0 +1,72 @@ +// +// PaywallContext.swift +// InAppKit +// +// Domain model for paywall presentation context. +// "This is the context I need to show my paywall" +// + +import Foundation +import StoreKit + +/// Context for showing a paywall. +/// "When I show a paywall, I need to know what products are available" +public struct PaywallContext { + /// What action triggered the paywall (e.g., "premium_feature", "export") + public let triggeredBy: String? + + /// Products available for purchase + public let availableProducts: [Product] + + /// Recommended product to highlight + public let recommendedProduct: Product? + + public init( + triggeredBy: String? = nil, + availableProducts: [Product] = [], + recommendedProduct: Product? = nil + ) { + self.triggeredBy = triggeredBy + self.availableProducts = availableProducts + self.recommendedProduct = recommendedProduct ?? availableProducts.first + } + + // MARK: - Marketing Information Helpers + + /// Get marketing badge for a product + @MainActor + public func badge(for product: Product) -> String? { + InAppKit.shared.badge(for: product.id) + } + + /// Get marketing features for a product + @MainActor + public func marketingFeatures(for product: Product) -> [String]? { + InAppKit.shared.marketingFeatures(for: product.id) + } + + /// Get promotional text for a product + @MainActor + public func promoText(for product: Product) -> String? { + InAppKit.shared.promoText(for: product.id) + } + + /// Get all marketing information for a product + @MainActor + public func marketingInfo(for product: Product) -> (badge: String?, features: [String]?, promoText: String?) { + ( + badge: badge(for: product), + features: marketingFeatures(for: product), + promoText: promoText(for: product) + ) + } + + /// Get products with their marketing information + @MainActor + public var productsWithMarketing: [(product: Product, badge: String?, features: [String]?, promoText: String?)] { + availableProducts.map { product in + let info = marketingInfo(for: product) + return (product: product, badge: info.badge, features: info.features, promoText: info.promoText) + } + } +} diff --git a/Sources/InAppKit/Core/Domain/ProductDefinition.swift b/Sources/InAppKit/Core/Domain/ProductDefinition.swift new file mode 100644 index 0000000..7526ea4 --- /dev/null +++ b/Sources/InAppKit/Core/Domain/ProductDefinition.swift @@ -0,0 +1,133 @@ +// +// ProductDefinition.swift +// InAppKit +// +// Domain model for defining products to sell. +// "I'm defining what products I want to sell in my app" +// + +import Foundation +import SwiftUI +import StoreKit +import OSLog + +// MARK: - Discount Rule + +/// Rule for calculating relative discounts between products. +/// "I want to show how much users save with yearly vs monthly" +public struct DiscountRule: Sendable { + public let comparedTo: String // base product ID + public let style: Style + public let color: Color? + + public init(comparedTo baseProductId: String, style: Style = .percentage, color: Color? = nil) { + self.comparedTo = baseProductId + self.style = style + self.color = color + } + + public enum Style: Sendable { + case percentage // "31% off" + case amount // "Save $44" + case freeTime // "2 months free" + } +} + +// MARK: - Product Definition + +/// Defines a product I want to sell. +/// "This is a product with these features and this marketing info" +public struct ProductDefinition: @unchecked Sendable { + public let id: String + public let features: [AnyHashable] + public let badge: String? + public let badgeColor: Color? + public let marketingFeatures: [String]? + public let promoText: String? + public let discountRule: DiscountRule? + + public init( + _ id: String, + features: [AnyHashable] = [], + badge: String? = nil, + badgeColor: Color? = nil, + marketingFeatures: [String]? = nil, + promoText: String? = nil, + discountRule: DiscountRule? = nil + ) { + self.id = id + self.features = features + self.badge = badge + self.badgeColor = badgeColor + self.marketingFeatures = marketingFeatures + self.promoText = promoText + self.discountRule = discountRule + } +} + +// MARK: - Product() Convenience Functions + +/// Create a product without features +public func Product(_ id: String) -> ProductDefinition { + ProductDefinition(id, features: []) +} + +/// Create a product with features (type-safe) +public func Product(_ id: String, features: [T]) -> ProductDefinition { + ProductDefinition(id, features: features.map { AnyHashable($0) }) +} + +/// Create a product with all cases of a feature enum (type-safe) +public func Product(_ id: String, features: T.Type) -> ProductDefinition { + ProductDefinition(id, features: T.allCases.map { AnyHashable($0) }) +} + +// MARK: - Fluent API Extensions + +public extension ProductDefinition { + /// Add a promotional badge + func withBadge(_ badge: String) -> ProductDefinition { + ProductDefinition( + id, features: features, badge: badge, badgeColor: badgeColor, + marketingFeatures: marketingFeatures, promoText: promoText, discountRule: discountRule + ) + } + + /// Add a promotional badge with custom color + func withBadge(_ badge: String, color: Color) -> ProductDefinition { + ProductDefinition( + id, features: features, badge: badge, badgeColor: color, + marketingFeatures: marketingFeatures, promoText: promoText, discountRule: discountRule + ) + } + + /// Add marketing features (bullet points in UI) + func withMarketingFeatures(_ features: [String]) -> ProductDefinition { + ProductDefinition( + id, features: self.features, badge: badge, badgeColor: badgeColor, + marketingFeatures: features, promoText: promoText, discountRule: discountRule + ) + } + + /// Add promotional text + func withPromoText(_ text: String) -> ProductDefinition { + ProductDefinition( + id, features: features, badge: badge, badgeColor: badgeColor, + marketingFeatures: marketingFeatures, promoText: text, discountRule: discountRule + ) + } + + /// Add relative discount calculation + func withRelativeDiscount(comparedTo baseProductId: String, style: DiscountRule.Style = .percentage, color: Color? = nil) -> ProductDefinition { + let rule = DiscountRule(comparedTo: baseProductId, style: style, color: color) + + #if DEBUG + Logger.statistics.debug("🔵 Creating ProductDefinition with discount: \(self.id) -> \(baseProductId)") + #endif + + return ProductDefinition( + id, features: features, badge: badge, badgeColor: badgeColor, + marketingFeatures: marketingFeatures, promoText: promoText, discountRule: rule + ) + } +} diff --git a/Sources/InAppKit/Core/Domain/PurchaseState.swift b/Sources/InAppKit/Core/Domain/PurchaseState.swift new file mode 100644 index 0000000..ebded9e --- /dev/null +++ b/Sources/InAppKit/Core/Domain/PurchaseState.swift @@ -0,0 +1,56 @@ +// +// PurchaseState.swift +// InAppKit +// +// Pure domain model for tracking purchase state. +// No StoreKit dependency - 100% testable. +// + +import Foundation + +/// Represents the current purchase state - pure value type +public struct PurchaseState: Equatable, Sendable { + public private(set) var purchasedProductIDs: Set + + public init(purchasedProductIDs: Set = []) { + self.purchasedProductIDs = purchasedProductIDs + } + + // MARK: - Queries + + /// Check if user has any active purchases + public var hasAnyPurchase: Bool { + !purchasedProductIDs.isEmpty + } + + /// Check if a specific product is purchased + public func isPurchased(_ productId: String) -> Bool { + purchasedProductIDs.contains(productId) + } + + // MARK: - Commands (return new state - immutable) + + /// Add a purchased product + public func withPurchase(_ productId: String) -> PurchaseState { + var newIDs = purchasedProductIDs + newIDs.insert(productId) + return PurchaseState(purchasedProductIDs: newIDs) + } + + /// Add multiple purchased products + public func withPurchases(_ productIds: Set) -> PurchaseState { + PurchaseState(purchasedProductIDs: purchasedProductIDs.union(productIds)) + } + + /// Remove a purchase (e.g., subscription expired) + public func withoutPurchase(_ productId: String) -> PurchaseState { + var newIDs = purchasedProductIDs + newIDs.remove(productId) + return PurchaseState(purchasedProductIDs: newIDs) + } + + /// Clear all purchases + public func cleared() -> PurchaseState { + PurchaseState(purchasedProductIDs: []) + } +} diff --git a/Sources/InAppKit/Core/InAppKit.swift b/Sources/InAppKit/Core/InAppKit.swift index ae5af37..881876c 100644 --- a/Sources/InAppKit/Core/InAppKit.swift +++ b/Sources/InAppKit/Core/InAppKit.swift @@ -2,7 +2,8 @@ // InAppKit.swift // InAppKit // -// User-Centric InAppKit with Auto-Configuration +// User-Centric InAppKit with Auto-Configuration. +// Delegates to pure domain models and uses Store for infrastructure. // import Foundation @@ -14,254 +15,242 @@ import OSLog @MainActor public class InAppKit { public static let shared = InAppKit() - - public var purchasedProductIDs: Set = [] + + // MARK: - Domain Models (Pure, Testable) + + private var purchaseState: PurchaseState = PurchaseState() + private var featureRegistry: FeatureRegistry = FeatureRegistry() + private var marketingRegistry: MarketingRegistry = MarketingRegistry() + + // MARK: - Infrastructure (Store) + + /// The store - where products are purchased + private let store: any Store + + // MARK: - StoreKit State + public var availableProducts: [Product] = [] public var isPurchasing = false public var purchaseError: Error? public var isInitialized = false - - // Feature-based configuration storage - private var featureToProductMapping: [AnyHashable: [String]] = [:] - private var productToFeatureMapping: [String: [AnyHashable]] = [:] - private var productMarketingInfo: [String: (badge: String?, badgeColor: Color?, features: [String]?, promoText: String?, relativeDiscountConfig: RelativeDiscountConfig?)] = [:] - + private var updateListenerTask: Task? - + + // MARK: - Public Accessors (Delegates to Domain Models) + + public var purchasedProductIDs: Set { + purchaseState.purchasedProductIDs + } + + public var hasAnyPurchase: Bool { + purchaseState.hasAnyPurchase + } + + @available(*, deprecated, message: "Use hasAnyPurchase for clearer semantics") + public var isPremium: Bool { + hasAnyPurchase + } + + // MARK: - Initialization + + /// Creates InAppKit with the real AppStore private init() { + self.store = AppStore() updateListenerTask = listenForTransactions() Task { - await updatePurchasedProducts() + await refreshPurchases() } } - + + /// Creates InAppKit with a custom Store (for testing) + internal init(store: any Store) { + self.store = store + updateListenerTask = listenForTransactions() + Task { + await refreshPurchases() + } + } + + #if DEBUG + /// Reset shared instance with a mock store (for testing) + public static func configure(with store: any Store) -> InAppKit { + return InAppKit(store: store) + } + #endif + deinit { // Task cleanup happens automatically } - - - - /// Initialize with product configurations (for fluent API) - internal func initialize(with productConfigs: [InternalProductConfig]) async { - let productIDs = productConfigs.map { $0.id } - - // Register features and marketing info - for config in productConfigs { - // Register features - for feature in config.features { - registerFeature(feature, productIds: [config.id]) + + // MARK: - Configuration + + internal func initialize(with products: [ProductDefinition]) async { + let productIDs = products.map { $0.id } + + // Register features using domain model + for product in products { + for feature in product.features { + featureRegistry = featureRegistry.withFeature(feature, productIds: [product.id]) } + } + + // Register marketing info using domain model + marketingRegistry = marketingRegistry.withMarketing(from: products) - // Store marketing information - productMarketingInfo[config.id] = ( - badge: config.badge, - badgeColor: config.badgeColor, - features: config.marketingFeatures, - promoText: config.promoText, - relativeDiscountConfig: config.relativeDiscountConfig - ) - - #if DEBUG - if let discountConfig = config.relativeDiscountConfig { - Logger.statistics.info("📊 Stored relativeDiscountConfig for \(config.id): comparing to \(discountConfig.baseProductId), style: \(String(describing: discountConfig.style))") + #if DEBUG + for product in products { + if let discountRule = product.discountRule { + Logger.statistics.info("📊 Stored discountRule for \(product.id): comparing to \(discountRule.comparedTo), style: \(String(describing: discountRule.style))") } - #endif } + #endif await loadProducts(productIds: productIDs) isInitialized = true } - - + + // MARK: - Store Operations (Delegates to Store) + public func loadProducts(productIds: [String]) async { do { - let products = try await Product.products(for: productIds) + let products = try await store.products(for: Set(productIds)) self.availableProducts = products - Logger.statistics.info("Loaded \(products.count) products") } catch { Logger.statistics.error("Failed to load products: \(error.localizedDescription)") purchaseError = error } } - + public func purchase(_ product: Product) async throws { isPurchasing = true purchaseError = nil - + defer { isPurchasing = false } - - let result = try await product.purchase() - - switch result { - case .success(let verification): - let transaction = try checkVerified(verification) - await updatePurchasedProducts() - await transaction.finish() - case .userCancelled: - break - case .pending: - break - @unknown default: + + let outcome = try await store.purchase(product) + + switch outcome { + case .success: + await refreshPurchases() + case .cancelled, .pending: break } } - + public func restorePurchases() async { - await updatePurchasedProducts() + do { + let restored = try await store.restore() + purchaseState = PurchaseState(purchasedProductIDs: restored) + } catch { + Logger.statistics.error("Failed to restore: \(error.localizedDescription)") + purchaseError = error + } } - + + // MARK: - Purchase State (Delegates to PurchaseState) + public func isPurchased(_ productId: String) -> Bool { - return purchasedProductIDs.contains(productId) - } - - /// Check if user has any active purchases - public var hasAnyPurchase: Bool { - return !purchasedProductIDs.isEmpty + purchaseState.isPurchased(productId) } - - /// Legacy compatibility - use hasAnyPurchase instead - @available(*, deprecated, message: "Use hasAnyPurchase for clearer semantics") - public var isPremium: Bool { - return hasAnyPurchase - } - - - /// Check if user has access to a specific feature + + // MARK: - Feature Access (Delegates to AccessControl) + public func hasAccess(to feature: F) -> Bool { - return hasAccess(to: AnyHashable(feature)) + hasAccess(to: AnyHashable(feature)) } - /// Check if user has access to a feature (AnyHashable version) public func hasAccess(to feature: AnyHashable) -> Bool { - let requiredProducts = featureToProductMapping[feature] ?? [] - - // If no products mapped to this feature, fall back to any purchase check - if requiredProducts.isEmpty { - return hasAnyPurchase - } - - return requiredProducts.contains { productId in - purchasedProductIDs.contains(productId) - } + AccessControl.hasAccess( + to: feature, + purchaseState: purchaseState, + featureRegistry: featureRegistry + ) } - - /// Get products that provide a specific feature + public func products(for feature: F) -> [Product] { let featureKey = AnyHashable(feature) - let productIds = featureToProductMapping[featureKey] ?? [] - + let productIds = featureRegistry.productIds(for: featureKey) + return availableProducts.filter { product in productIds.contains(product.id) } } - - /// Register a feature with its product IDs (used by configuration builder) + + // MARK: - Feature Registration (Delegates to FeatureRegistry) + public func registerFeature(_ feature: AnyHashable, productIds: [String]) { - featureToProductMapping[feature] = productIds - for productId in productIds { - productToFeatureMapping[productId, default: []].append(feature) - } + featureRegistry = featureRegistry.withFeature(feature, productIds: productIds) } - - /// Check if a feature is registered (for validation) + public func isFeatureRegistered(_ feature: F) -> Bool { - return isFeatureRegistered(AnyHashable(feature)) + isFeatureRegistered(AnyHashable(feature)) } - /// Check if a feature is registered (AnyHashable version) public func isFeatureRegistered(_ feature: AnyHashable) -> Bool { - return featureToProductMapping[feature] != nil + featureRegistry.isRegistered(feature) } - // MARK: - Marketing Information + // MARK: - Marketing Information (Delegates to MarketingRegistry) - /// Get marketing badge for a product public func badge(for productId: String) -> String? { - return productMarketingInfo[productId]?.badge + marketingRegistry.badge(for: productId) } - /// Get badge color for a product public func badgeColor(for productId: String) -> Color? { - return productMarketingInfo[productId]?.badgeColor + marketingRegistry.badgeColor(for: productId) } - /// Get marketing features for a product public func marketingFeatures(for productId: String) -> [String]? { - return productMarketingInfo[productId]?.features + marketingRegistry.features(for: productId) } - /// Get promotional text for a product public func promoText(for productId: String) -> String? { - return productMarketingInfo[productId]?.promoText + marketingRegistry.promoText(for: productId) } - /// Get relative discount configuration for a product - public func relativeDiscountConfig(for productId: String) -> RelativeDiscountConfig? { - return productMarketingInfo[productId]?.relativeDiscountConfig + public func discountRule(for productId: String) -> DiscountRule? { + marketingRegistry.discountRule(for: productId) } - + // MARK: - Development Helpers - + #if DEBUG - /// Development helper to simulate purchases public func simulatePurchase(_ productId: String) { - purchasedProductIDs.insert(productId) + purchaseState = purchaseState.withPurchase(productId) } - - /// Development helper to clear purchases + public func clearPurchases() { - purchasedProductIDs.removeAll() + purchaseState = purchaseState.cleared() + } + + public func clearFeatures() { + featureRegistry = FeatureRegistry() + } + + public func clearMarketing() { + marketingRegistry = MarketingRegistry() } #endif - + // MARK: - Private Methods - - private func checkVerified(_ result: VerificationResult) throws -> T { - switch result { - case .unverified: - throw StoreError.failedVerification - case .verified(let safe): - return safe - } - } - - private func updatePurchasedProducts() async { - var purchasedProductIDs: Set = [] - - for await result in Transaction.currentEntitlements { - do { - let transaction = try checkVerified(result) - - Logger.statistics.info("Found purchased product: \(transaction.productID)") - - switch transaction.productType { - case .consumable: - break - case .nonConsumable: - purchasedProductIDs.insert(transaction.productID) - case .autoRenewable: - purchasedProductIDs.insert(transaction.productID) - case .nonRenewable: - purchasedProductIDs.insert(transaction.productID) - default: - break - } - } catch { - Logger.statistics.error("Failed to verify transaction: \(error.localizedDescription)") - } + + private func refreshPurchases() async { + do { + let purchased = try await store.purchases() + purchaseState = PurchaseState(purchasedProductIDs: purchased) + } catch { + Logger.statistics.error("Failed to refresh purchases: \(error.localizedDescription)") } - - self.purchasedProductIDs = purchasedProductIDs } - + private func listenForTransactions() -> Task { return Task { for await result in Transaction.updates { do { let transaction = try checkVerified(result) - await updatePurchasedProducts() + await refreshPurchases() await transaction.finish() } catch { Logger.statistics.error("Failed to verify transaction: \(error.localizedDescription)") @@ -269,8 +258,19 @@ public class InAppKit { } } } + + private func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .unverified: + throw StoreError.failedVerification + case .verified(let safe): + return safe + } + } } +// MARK: - Store Error + public enum StoreError: Error { case failedVerification case productNotFound(String) diff --git a/Sources/InAppKit/Core/Store.swift b/Sources/InAppKit/Core/Store.swift new file mode 100644 index 0000000..db87232 --- /dev/null +++ b/Sources/InAppKit/Core/Store.swift @@ -0,0 +1,36 @@ +// +// Store.swift +// InAppKit +// +// The Store protocol - core domain concept. +// "My app has a Store where users can buy products" +// +// Tests use MockStore (auto-generated by @Mockable). +// + +import StoreKit +import Mockable + +/// The Store - where users buy products. +/// This is the core domain concept from developer's mental model. +@Mockable +public protocol Store: Sendable { + /// Get available products from the store + func products(for ids: Set) async throws -> [Product] + + /// Purchase a product + func purchase(_ product: Product) async throws -> PurchaseOutcome + + /// Get what the user has purchased (their entitlements) + func purchases() async throws -> Set + + /// Restore previous purchases + func restore() async throws -> Set +} + +/// Result of a purchase - simple domain model +public enum PurchaseOutcome: Equatable, Sendable { + case success(productId: String) + case cancelled + case pending +} diff --git a/Sources/InAppKit/Infrastructure/AppStore.swift b/Sources/InAppKit/Infrastructure/AppStore.swift new file mode 100644 index 0000000..850f4f7 --- /dev/null +++ b/Sources/InAppKit/Infrastructure/AppStore.swift @@ -0,0 +1,94 @@ +// +// AppStore.swift +// InAppKit +// +// Real Store implementation using StoreKitProvider. +// Delegates to StoreKitProvider for actual Apple API calls. +// This makes AppStore itself testable with MockStoreKitProvider. +// + +import StoreKit +import OSLog + +/// Real Store implementation. +/// Uses StoreKitProvider for actual StoreKit calls (testable via DI). +public final class AppStore: Store, @unchecked Sendable { + + private let provider: any StoreKitProvider + + /// Creates AppStore with real StoreKit + public init() { + self.provider = DefaultStoreKitProvider() + } + + /// Creates AppStore with custom provider (for testing) + public init(provider: any StoreKitProvider) { + self.provider = provider + } + + // MARK: - Store Protocol + + public func products(for ids: Set) async throws -> [Product] { + let products = try await provider.fetchProducts(for: ids) + Logger.statistics.info("Loaded \(products.count) products") + return products + } + + public func purchase(_ product: Product) async throws -> PurchaseOutcome { + let result = try await product.purchase() + + switch result { + case .success(let verification): + let transaction = try checkVerified(verification) + await transaction.finish() + Logger.statistics.info("Purchase successful: \(product.id)") + return .success(productId: product.id) + + case .userCancelled: + return .cancelled + + case .pending: + return .pending + + @unknown default: + return .cancelled + } + } + + public func purchases() async throws -> Set { + var purchased: Set = [] + + let entitlements = try await provider.currentEntitlements() + for result in entitlements { + let transaction = try checkVerified(result) + + switch transaction.productType { + case .consumable: + break + case .nonConsumable, .autoRenewable, .nonRenewable: + purchased.insert(transaction.productID) + default: + break + } + } + + Logger.statistics.info("Found \(purchased.count) purchases") + return purchased + } + + public func restore() async throws -> Set { + try await provider.sync() + return try await purchases() + } + + // MARK: - Private + + private func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .unverified: + throw StoreError.failedVerification + case .verified(let safe): + return safe + } + } +} diff --git a/Sources/InAppKit/Infrastructure/StoreKitProvider.swift b/Sources/InAppKit/Infrastructure/StoreKitProvider.swift new file mode 100644 index 0000000..83990af --- /dev/null +++ b/Sources/InAppKit/Infrastructure/StoreKitProvider.swift @@ -0,0 +1,47 @@ +// +// StoreKitProvider.swift +// InAppKit +// +// Protocol abstracting StoreKit static methods. +// Like claudebar's CLIExecutor - allows testing infrastructure code. +// Tests use MockStoreKitProvider (auto-generated by @Mockable). +// + +import StoreKit +import Mockable + +/// Abstracts StoreKit static methods for testability. +/// This is the lowest level - wraps actual Apple APIs. +@Mockable +public protocol StoreKitProvider: Sendable { + /// Fetch products from App Store (wraps Product.products(for:)) + func fetchProducts(for ids: Set) async throws -> [Product] + + /// Get current entitlements (wraps Transaction.currentEntitlements) + func currentEntitlements() async throws -> [VerificationResult] + + /// Sync with App Store (wraps AppStore.sync()) + func sync() async throws +} + +/// Default implementation using real StoreKit APIs. +public final class DefaultStoreKitProvider: StoreKitProvider, @unchecked Sendable { + + public init() {} + + public func fetchProducts(for ids: Set) async throws -> [Product] { + try await Product.products(for: ids) + } + + public func currentEntitlements() async throws -> [VerificationResult] { + var results: [VerificationResult] = [] + for await result in Transaction.currentEntitlements { + results.append(result) + } + return results + } + + public func sync() async throws { + try await StoreKit.AppStore.sync() + } +} diff --git a/Sources/InAppKit/Modifiers/PurchaseSetup.swift b/Sources/InAppKit/Modifiers/PurchaseSetup.swift new file mode 100644 index 0000000..0074041 --- /dev/null +++ b/Sources/InAppKit/Modifiers/PurchaseSetup.swift @@ -0,0 +1,244 @@ +// +// PurchaseSetup.swift +// InAppKit +// +// SwiftUI integration for setting up purchases. +// "I'm setting up my app to handle purchases" +// + +import Foundation +import SwiftUI +import StoreKit + +// MARK: - Purchase Setup Builder + +/// Builder for configuring purchases in your app. +/// Internal implementation - users interact via .withPurchases() modifier. +@MainActor +public class PurchaseSetup { + internal var products: [ProductDefinition] = [] + internal var paywallBuilder: ((PaywallContext) -> AnyView)? + internal var paywallHeaderBuilder: (() -> AnyView)? + internal var paywallFeaturesBuilder: (() -> AnyView)? + internal var termsBuilder: (() -> AnyView)? + internal var privacyBuilder: (() -> AnyView)? + internal var termsURL: URL? + internal var privacyURL: URL? + + public init() {} + + // MARK: - Product Configuration + + public func withPurchases(_ productId: String) -> PurchaseSetup { + products.append(ProductDefinition(productId)) + return self + } + + public func withPurchases(_ productIds: String...) -> PurchaseSetup { + products.append(contentsOf: productIds.map { ProductDefinition($0) }) + return self + } + + public func withPurchases(products: [ProductDefinition]) -> PurchaseSetup { + self.products.append(contentsOf: products) + return self + } + + // MARK: - Paywall Configuration + + public func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> Content) -> PurchaseSetup { + paywallBuilder = { context in AnyView(builder(context)) } + return self + } + + public func withPaywallHeader(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup { + paywallHeaderBuilder = { AnyView(builder()) } + return self + } + + public func withPaywallFeatures(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup { + paywallFeaturesBuilder = { AnyView(builder()) } + return self + } + + // MARK: - Legal Configuration + + public func withTerms(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup { + termsBuilder = { AnyView(builder()) } + return self + } + + public func withTerms(url: URL) -> PurchaseSetup { + termsURL = url + return self + } + + public func withPrivacy(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup { + privacyBuilder = { AnyView(builder()) } + return self + } + + public func withPrivacy(url: URL) -> PurchaseSetup { + privacyURL = url + return self + } + + // MARK: - Internal + + internal func setup() async { + await InAppKit.shared.initialize(with: products) + } +} + +// MARK: - Environment Keys + +private struct PaywallBuilderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: ((PaywallContext) -> AnyView)? = nil +} + +private struct TermsBuilderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil +} + +private struct PrivacyBuilderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil +} + +private struct PaywallHeaderBuilderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil +} + +private struct PaywallFeaturesBuilderKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: (() -> AnyView)? = nil +} + +private struct TermsURLKey: EnvironmentKey { + static let defaultValue: URL? = nil +} + +private struct PrivacyURLKey: EnvironmentKey { + static let defaultValue: URL? = nil +} + +public extension EnvironmentValues { + var paywallBuilder: ((PaywallContext) -> AnyView)? { + get { self[PaywallBuilderKey.self] } + set { self[PaywallBuilderKey.self] = newValue } + } + + var termsBuilder: (() -> AnyView)? { + get { self[TermsBuilderKey.self] } + set { self[TermsBuilderKey.self] = newValue } + } + + var privacyBuilder: (() -> AnyView)? { + get { self[PrivacyBuilderKey.self] } + set { self[PrivacyBuilderKey.self] = newValue } + } + + var paywallHeaderBuilder: (() -> AnyView)? { + get { self[PaywallHeaderBuilderKey.self] } + set { self[PaywallHeaderBuilderKey.self] = newValue } + } + + var paywallFeaturesBuilder: (() -> AnyView)? { + get { self[PaywallFeaturesBuilderKey.self] } + set { self[PaywallFeaturesBuilderKey.self] = newValue } + } + + var termsURL: URL? { + get { self[TermsURLKey.self] } + set { self[TermsURLKey.self] = newValue } + } + + var privacyURL: URL? { + get { self[PrivacyURLKey.self] } + set { self[PrivacyURLKey.self] = newValue } + } +} + +// MARK: - Purchase Enabled View + +/// A view with purchases enabled. +/// Internal wrapper - users don't see this directly. +@MainActor +public struct PurchaseEnabledView: View { + let content: Content + let config: PurchaseSetup + + internal init(content: Content, config: PurchaseSetup) { + self.content = content + self.config = config + } + + public var body: some View { + content.modifier(PurchaseSetupModifier(config: config)) + } + + public func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> PaywallContent) -> PurchaseEnabledView { + PurchaseEnabledView(content: content, config: config.withPaywall(builder)) + } + + public func withTerms(@ViewBuilder _ builder: @escaping () -> TermsContent) -> PurchaseEnabledView { + PurchaseEnabledView(content: content, config: config.withTerms(builder)) + } + + public func withTerms(url: URL) -> PurchaseEnabledView { + PurchaseEnabledView(content: content, config: config.withTerms(url: url)) + } + + public func withPrivacy(@ViewBuilder _ builder: @escaping () -> PrivacyContent) -> PurchaseEnabledView { + PurchaseEnabledView(content: content, config: config.withPrivacy(builder)) + } + + public func withPrivacy(url: URL) -> PurchaseEnabledView { + PurchaseEnabledView(content: content, config: config.withPrivacy(url: url)) + } + + public func withPaywallHeader(@ViewBuilder _ builder: @escaping () -> HeaderContent) -> PurchaseEnabledView { + PurchaseEnabledView(content: content, config: config.withPaywallHeader(builder)) + } + + public func withPaywallFeatures(@ViewBuilder _ builder: @escaping () -> FeaturesContent) -> PurchaseEnabledView { + PurchaseEnabledView(content: content, config: config.withPaywallFeatures(builder)) + } +} + +// MARK: - Purchase Setup Modifier + +private struct PurchaseSetupModifier: ViewModifier { + let config: PurchaseSetup + + func body(content: Content) -> some View { + content + .environment(\.paywallBuilder, config.paywallBuilder) + .environment(\.paywallHeaderBuilder, config.paywallHeaderBuilder) + .environment(\.paywallFeaturesBuilder, config.paywallFeaturesBuilder) + .environment(\.termsBuilder, config.termsBuilder) + .environment(\.privacyBuilder, config.privacyBuilder) + .environment(\.termsURL, config.termsURL) + .environment(\.privacyURL, config.privacyURL) + .task { + await config.setup() + } + } +} + +// MARK: - View Extensions + +public extension View { + /// Set up purchases with products + func withPurchases(products: [ProductDefinition]) -> PurchaseEnabledView { + PurchaseEnabledView(content: self, config: PurchaseSetup().withPurchases(products: products)) + } + + func withPurchases(_ productId: String) -> PurchaseEnabledView { + PurchaseEnabledView(content: self, config: PurchaseSetup().withPurchases(productId)) + } + + func withPurchases(_ productIds: String...) -> PurchaseEnabledView { + let config = PurchaseSetup() + config.products.append(contentsOf: productIds.map { ProductDefinition($0) }) + return PurchaseEnabledView(content: self, config: config) + } +} diff --git a/Sources/InAppKit/UI/Components/PaywallComponents.swift b/Sources/InAppKit/UI/Components/PaywallComponents.swift index 6b2a13b..5d952e8 100644 --- a/Sources/InAppKit/UI/Components/PaywallComponents.swift +++ b/Sources/InAppKit/UI/Components/PaywallComponents.swift @@ -331,8 +331,8 @@ public extension View { subtitle: String = "paywall.header.subtitle".localized(fallback: "Unlock advanced features and premium support"), iconColor: Color = .blue, backgroundColor: Color = Color.blue.opacity(0.2) - ) -> ChainableStoreKitView { - let config = StoreKitConfiguration() + ) -> PurchaseEnabledView { + let config = PurchaseSetup() let newConfig = config.withPaywallHeader { PaywallHeader( icon: .system(systemIcon), @@ -342,7 +342,7 @@ public extension View { backgroundColor: backgroundColor ) } - return ChainableStoreKitView(content: self, config: newConfig) + return PurchaseEnabledView(content: self, config: newConfig) } /// Add a custom paywall header to the chain with asset icon @@ -352,8 +352,8 @@ public extension View { subtitle: String = "paywall.header.subtitle".localized(fallback: "Unlock advanced features and premium support"), iconColor: Color = .blue, backgroundColor: Color = Color.blue.opacity(0.2) - ) -> ChainableStoreKitView { - let config = StoreKitConfiguration() + ) -> PurchaseEnabledView { + let config = PurchaseSetup() let newConfig = config.withPaywallHeader { PaywallHeader( icon: .asset(assetIcon), @@ -363,7 +363,7 @@ public extension View { backgroundColor: backgroundColor ) } - return ChainableStoreKitView(content: self, config: newConfig) + return PurchaseEnabledView(content: self, config: newConfig) } /// Add a custom paywall header to the chain with PaywallIcon @@ -373,8 +373,8 @@ public extension View { subtitle: String = "paywall.header.subtitle".localized(fallback: "Unlock advanced features and premium support"), iconColor: Color = .blue, backgroundColor: Color = Color.blue.opacity(0.2) - ) -> ChainableStoreKitView { - let config = StoreKitConfiguration() + ) -> PurchaseEnabledView { + let config = PurchaseSetup() let newConfig = config.withPaywallHeader { PaywallHeader( icon: icon, @@ -384,18 +384,18 @@ public extension View { backgroundColor: backgroundColor ) } - return ChainableStoreKitView(content: self, config: newConfig) + return PurchaseEnabledView(content: self, config: newConfig) } /// Add custom paywall features to the chain func withPaywallFeatures( title: String = "paywall.features.title".localized(fallback: "What's Included"), features: [PaywallFeature] - ) -> ChainableStoreKitView { - let config = StoreKitConfiguration() + ) -> PurchaseEnabledView { + let config = PurchaseSetup() let newConfig = config.withPaywallFeatures { PaywallFeatures(title: title, features: features) } - return ChainableStoreKitView(content: self, config: newConfig) + return PurchaseEnabledView(content: self, config: newConfig) } } \ No newline at end of file diff --git a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift index 1362e66..78e98ff 100644 --- a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift +++ b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift @@ -74,7 +74,7 @@ public struct PurchaseOptionCard: View { self.promoText = promoText } - /// Computed promo text - uses manual promoText if provided, otherwise calculates from relativeDiscountConfig + /// Computed promo text - uses manual promoText if provided, otherwise calculates from discountRule @MainActor private var displayPromoText: String? { // Manual promo text takes priority @@ -82,20 +82,20 @@ public struct PurchaseOptionCard: View { return manualPromo } - // Calculate from relative discount config - guard let discountConfig = InAppKit.shared.relativeDiscountConfig(for: product.id) else { + // Calculate from discount rule + guard let discountRule = InAppKit.shared.discountRule(for: product.id) else { return nil } - return calculateRelativeDiscount(config: discountConfig) + return calculateRelativeDiscount(rule: discountRule) } - /// Computed promo color - from relativeDiscountConfig or default orange + /// Computed promo color - from discountRule or default orange @MainActor private var displayPromoColor: Color { - // Get color from relative discount config - if let discountConfig = InAppKit.shared.relativeDiscountConfig(for: product.id), - let customColor = discountConfig.color { + // Get color from discount rule + if let discountRule = InAppKit.shared.discountRule(for: product.id), + let customColor = discountRule.color { return customColor } // Default to orange @@ -104,9 +104,9 @@ public struct PurchaseOptionCard: View { /// Calculate the discount string based on comparison product @MainActor - private func calculateRelativeDiscount(config: RelativeDiscountConfig) -> String? { + private func calculateRelativeDiscount(rule: DiscountRule) -> String? { // Find the base product to compare against - guard let baseProduct = InAppKit.shared.availableProducts.first(where: { $0.id == config.baseProductId }) else { + guard let baseProduct = InAppKit.shared.availableProducts.first(where: { $0.id == rule.comparedTo }) else { return nil } @@ -139,7 +139,7 @@ public struct PurchaseOptionCard: View { basePeriod: basePeriod ) - switch config.style { + switch rule.style { case .percentage: let multiplier = NSDecimalNumber(integerLiteral: periodMultiplier(currentPeriod, comparedTo: basePeriod)) let basePriceNumber = basePrice as NSDecimalNumber diff --git a/Tests/InAppKitTests/Domain/AccessControlTests.swift b/Tests/InAppKitTests/Domain/AccessControlTests.swift new file mode 100644 index 0000000..7ace203 --- /dev/null +++ b/Tests/InAppKitTests/Domain/AccessControlTests.swift @@ -0,0 +1,233 @@ +import Testing +@testable import InAppKit + +// Test feature enum for this file +private enum AccessTestFeature: String, AppFeature, CaseIterable { + case sync + case export + case premium +} + +@Suite +struct AccessControlTests { + + // MARK: - Basic Access Checks + + @Test + func `user with no purchases has no access to registered feature`() { + // Given + let purchaseState = PurchaseState() + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + + // When + let hasAccess = AccessControl.hasAccess( + to: AnyHashable("sync"), + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(!hasAccess) + } + + @Test + func `user with correct purchase has access to feature`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + + // When + let hasAccess = AccessControl.hasAccess( + to: AnyHashable("sync"), + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(hasAccess) + } + + @Test + func `user with wrong purchase has no access`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.basic"]) + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + + // When + let hasAccess = AccessControl.hasAccess( + to: AnyHashable("sync"), + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(!hasAccess) + } + + // MARK: - Multiple Products Per Feature + + @Test + func `user has access when owning any product for feature`() { + // Given - sync available in both pro and premium + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.premium"]) + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro", "com.app.premium"]) + + // When + let hasAccess = AccessControl.hasAccess( + to: AnyHashable("sync"), + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(hasAccess) + } + + // MARK: - Fallback Behavior + + @Test + func `unregistered feature falls back to hasAnyPurchase when user has no purchases`() { + // Given + let purchaseState = PurchaseState() + let registry = FeatureRegistry() // Empty - feature not registered + + // When + let hasAccess = AccessControl.hasAccess( + to: AnyHashable("unregistered"), + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(!hasAccess) + } + + @Test + func `unregistered feature falls back to hasAnyPurchase when user has purchases`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.any"]) + let registry = FeatureRegistry() // Empty - feature not registered + + // When + let hasAccess = AccessControl.hasAccess( + to: AnyHashable("unregistered"), + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(hasAccess) // Fallback to hasAnyPurchase + } + + // MARK: - AppFeature Support + + @Test + func `hasAccess works with AppFeature enum`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let registry = FeatureRegistry() + .withFeature(AccessTestFeature.sync, productIds: ["com.app.pro"]) + + // When + let hasAccess = AccessControl.hasAccess( + to: AccessTestFeature.sync, + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(hasAccess) + } + + // MARK: - Batch Access Checks + + @Test + func `accessStatus returns status for multiple features`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("export"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("premium"), productIds: ["com.app.premium"]) + + // When + let status = AccessControl.accessStatus( + for: [AnyHashable("sync"), AnyHashable("export"), AnyHashable("premium")], + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(status[AnyHashable("sync")] == true) + #expect(status[AnyHashable("export")] == true) + #expect(status[AnyHashable("premium")] == false) + } + + // MARK: - Accessible Features + + @Test + func `accessibleFeatures returns all features user has access to`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("export"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("premium"), productIds: ["com.app.premium"]) + + // When + let accessible = AccessControl.accessibleFeatures( + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(accessible.count == 2) + #expect(accessible.contains(AnyHashable("sync"))) + #expect(accessible.contains(AnyHashable("export"))) + #expect(!accessible.contains(AnyHashable("premium"))) + } + + // MARK: - Missing Features + + @Test + func `missingFeatures returns features user lacks`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("export"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("premium"), productIds: ["com.app.premium"]) + + // When + let missing = AccessControl.missingFeatures( + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(missing.count == 1) + #expect(missing.contains(AnyHashable("premium"))) + } + + @Test + func `user with all purchases has no missing features`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro", "com.app.premium"]) + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("premium"), productIds: ["com.app.premium"]) + + // When + let missing = AccessControl.missingFeatures( + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(missing.isEmpty) + } +} diff --git a/Tests/InAppKitTests/Domain/FeatureRegistryTests.swift b/Tests/InAppKitTests/Domain/FeatureRegistryTests.swift new file mode 100644 index 0000000..665ee86 --- /dev/null +++ b/Tests/InAppKitTests/Domain/FeatureRegistryTests.swift @@ -0,0 +1,200 @@ +import Testing +@testable import InAppKit + +// Test feature enum for this file +private enum DomainTestFeature: String, AppFeature, CaseIterable { + case sync + case export + case premium +} + +@Suite +struct FeatureRegistryTests { + + // MARK: - Creating Registry + + @Test + func `empty registry has no features`() { + // Given + let registry = FeatureRegistry() + + // Then + #expect(registry.allFeatures.isEmpty) + #expect(registry.allProductIds.isEmpty) + } + + // MARK: - Registering Features + + @Test + func `register feature maps to product`() { + // Given + let registry = FeatureRegistry() + + // When + let newRegistry = registry.withFeature( + AnyHashable("sync"), + productIds: ["com.app.pro"] + ) + + // Then + #expect(newRegistry.isRegistered(AnyHashable("sync"))) + #expect(newRegistry.productIds(for: AnyHashable("sync")) == ["com.app.pro"]) + } + + @Test + func `register feature with multiple products`() { + // Given + let registry = FeatureRegistry() + + // When + let newRegistry = registry.withFeature( + AnyHashable("sync"), + productIds: ["com.app.pro", "com.app.premium"] + ) + + // Then + let productIds = newRegistry.productIds(for: AnyHashable("sync")) + #expect(productIds.count == 2) + #expect(productIds.contains("com.app.pro")) + #expect(productIds.contains("com.app.premium")) + } + + @Test + func `register multiple features to same product`() { + // Given + let registry = FeatureRegistry() + + // When + let newRegistry = registry + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("export"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("premium"), productIds: ["com.app.pro"]) + + // Then + let features = newRegistry.features(for: "com.app.pro") + #expect(features.count == 3) + #expect(features.contains(AnyHashable("sync"))) + #expect(features.contains(AnyHashable("export"))) + #expect(features.contains(AnyHashable("premium"))) + } + + // MARK: - Querying Registry + + @Test + func `isRegistered returns false for unknown feature`() { + // Given + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + + // Then + #expect(!registry.isRegistered(AnyHashable("unknown"))) + } + + @Test + func `productIds returns empty set for unknown feature`() { + // Given + let registry = FeatureRegistry() + + // Then + #expect(registry.productIds(for: AnyHashable("unknown")).isEmpty) + } + + @Test + func `features returns empty set for unknown product`() { + // Given + let registry = FeatureRegistry() + + // Then + #expect(registry.features(for: "com.app.unknown").isEmpty) + } + + @Test + func `allFeatures returns all registered features`() { + // Given + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + .withFeature(AnyHashable("export"), productIds: ["com.app.premium"]) + + // Then + #expect(registry.allFeatures.count == 2) + #expect(registry.allFeatures.contains(AnyHashable("sync"))) + #expect(registry.allFeatures.contains(AnyHashable("export"))) + } + + @Test + func `allProductIds returns all registered product ids`() { + // Given + let registry = FeatureRegistry() + .withFeature(AnyHashable("sync"), productIds: ["com.app.pro", "com.app.premium"]) + + // Then + #expect(registry.allProductIds.count == 2) + #expect(registry.allProductIds.contains("com.app.pro")) + #expect(registry.allProductIds.contains("com.app.premium")) + } + + // MARK: - AppFeature Convenience + + @Test + func `register AppFeature enum`() { + // Given + let registry = FeatureRegistry() + + // When + let newRegistry = registry.withFeature(DomainTestFeature.sync, productIds: ["com.app.pro"]) + + // Then + #expect(newRegistry.isRegistered(DomainTestFeature.sync)) + #expect(newRegistry.productIds(for: DomainTestFeature.sync).contains("com.app.pro")) + } + + @Test + func `register multiple AppFeature enums`() { + // Given + let registry = FeatureRegistry() + + // When + let newRegistry = registry + .withFeature(DomainTestFeature.sync, productIds: ["com.app.pro"]) + .withFeature(DomainTestFeature.export, productIds: ["com.app.pro"]) + .withFeature(DomainTestFeature.premium, productIds: ["com.app.premium"]) + + // Then + #expect(newRegistry.isRegistered(DomainTestFeature.sync)) + #expect(newRegistry.isRegistered(DomainTestFeature.export)) + #expect(newRegistry.isRegistered(DomainTestFeature.premium)) + } + + // MARK: - Immutability + + @Test + func `original registry unchanged after adding feature`() { + // Given + let original = FeatureRegistry() + + // When + let _ = original.withFeature(AnyHashable("sync"), productIds: ["com.app.pro"]) + + // Then + #expect(original.allFeatures.isEmpty) // Original unchanged + } + + // MARK: - Bulk Registration + + @Test + func `withFeatures registers multiple mappings`() { + // Given + let registry = FeatureRegistry() + + // When + let newRegistry = registry.withFeatures([ + (feature: AnyHashable("sync"), productIds: ["com.app.pro"]), + (feature: AnyHashable("export"), productIds: ["com.app.pro", "com.app.premium"]), + (feature: AnyHashable("premium"), productIds: ["com.app.premium"]) + ]) + + // Then + #expect(newRegistry.allFeatures.count == 3) + #expect(newRegistry.productIds(for: AnyHashable("export")).count == 2) + } +} diff --git a/Tests/InAppKitTests/Domain/MarketingRegistryTests.swift b/Tests/InAppKitTests/Domain/MarketingRegistryTests.swift new file mode 100644 index 0000000..62d7680 --- /dev/null +++ b/Tests/InAppKitTests/Domain/MarketingRegistryTests.swift @@ -0,0 +1,260 @@ +import Testing +import SwiftUI +@testable import InAppKit + +@Suite +struct MarketingRegistryTests { + + // MARK: - Creating Registry + + @Test + func `empty registry has no products`() { + // Given + let registry = MarketingRegistry() + + // Then + #expect(registry.allProductIds.isEmpty) + } + + // MARK: - Registering Marketing Info + + @Test + func `register marketing info for product`() { + // Given + let registry = MarketingRegistry() + let marketing = ProductMarketing( + badge: "Best Value", + badgeColor: .blue, + features: ["Cloud sync", "Premium support"], + promoText: "Save 44%" + ) + + // When + let newRegistry = registry.withMarketing("com.app.pro", marketing: marketing) + + // Then + #expect(newRegistry.badge(for: "com.app.pro") == "Best Value") + #expect(newRegistry.badgeColor(for: "com.app.pro") == .blue) + #expect(newRegistry.features(for: "com.app.pro")?.count == 2) + #expect(newRegistry.promoText(for: "com.app.pro") == "Save 44%") + } + + @Test + func `register marketing with discount rule`() { + // Given + let registry = MarketingRegistry() + let discountConfig = DiscountRule( + comparedTo: "com.app.monthly", + style: .percentage, + color: .green + ) + let marketing = ProductMarketing( + badge: "Popular", + discountRule: discountConfig + ) + + // When + let newRegistry = registry.withMarketing("com.app.yearly", marketing: marketing) + + // Then + let config = newRegistry.discountRule(for: "com.app.yearly") + #expect(config?.comparedTo == "com.app.monthly") + #expect(config?.style == .percentage) + #expect(config?.color == .green) + } + + // MARK: - Querying Registry + + @Test + func `badge returns nil for unknown product`() { + // Given + let registry = MarketingRegistry() + + // Then + #expect(registry.badge(for: "com.app.unknown") == nil) + } + + @Test + func `marketing returns nil for unknown product`() { + // Given + let registry = MarketingRegistry() + + // Then + #expect(registry.marketing(for: "com.app.unknown") == nil) + } + + @Test + func `allProductIds returns all registered products`() { + // Given + let registry = MarketingRegistry() + .withMarketing("com.app.pro", marketing: ProductMarketing(badge: "Pro")) + .withMarketing("com.app.premium", marketing: ProductMarketing(badge: "Premium")) + + // Then + #expect(registry.allProductIds.count == 2) + #expect(registry.allProductIds.contains("com.app.pro")) + #expect(registry.allProductIds.contains("com.app.premium")) + } + + @Test + func `productsWithBadges returns only products with badges`() { + // Given + let registry = MarketingRegistry() + .withMarketing("com.app.pro", marketing: ProductMarketing(badge: "Best Value")) + .withMarketing("com.app.basic", marketing: ProductMarketing(features: ["Basic feature"])) + .withMarketing("com.app.premium", marketing: ProductMarketing(badge: "Popular")) + + // Then + let badgeProducts = registry.productsWithBadges + #expect(badgeProducts.count == 2) + #expect(badgeProducts.contains("com.app.pro")) + #expect(badgeProducts.contains("com.app.premium")) + #expect(!badgeProducts.contains("com.app.basic")) + } + + // MARK: - ProductMarketing Domain Behavior + + @Test + func `hasMarketing is true when badge is set`() { + // Given + let marketing = ProductMarketing(badge: "Sale") + + // Then + #expect(marketing.hasMarketing) + } + + @Test + func `hasMarketing is true when features are set`() { + // Given + let marketing = ProductMarketing(features: ["Feature 1"]) + + // Then + #expect(marketing.hasMarketing) + } + + @Test + func `hasMarketing is true when promoText is set`() { + // Given + let marketing = ProductMarketing(promoText: "Save 50%") + + // Then + #expect(marketing.hasMarketing) + } + + @Test + func `hasMarketing is false when empty`() { + // Given + let marketing = ProductMarketing() + + // Then + #expect(!marketing.hasMarketing) + } + + @Test + func `hasBadge is true when badge is set`() { + // Given + let marketing = ProductMarketing(badge: "Popular") + + // Then + #expect(marketing.hasBadge) + } + + @Test + func `hasDiscountRule is true when rule is set`() { + // Given + let marketing = ProductMarketing( + discountRule: DiscountRule( + comparedTo: "com.app.monthly", + style: .percentage + ) + ) + + // Then + #expect(marketing.hasDiscountRule) + } + + // MARK: - Removing Marketing + + @Test + func `withoutMarketing removes product`() { + // Given + let registry = MarketingRegistry() + .withMarketing("com.app.pro", marketing: ProductMarketing(badge: "Pro")) + .withMarketing("com.app.premium", marketing: ProductMarketing(badge: "Premium")) + + // When + let newRegistry = registry.withoutMarketing(for: "com.app.pro") + + // Then + #expect(newRegistry.badge(for: "com.app.pro") == nil) + #expect(newRegistry.badge(for: "com.app.premium") == "Premium") + } + + @Test + func `cleared removes all marketing`() { + // Given + let registry = MarketingRegistry() + .withMarketing("com.app.pro", marketing: ProductMarketing(badge: "Pro")) + .withMarketing("com.app.premium", marketing: ProductMarketing(badge: "Premium")) + + // When + let newRegistry = registry.cleared() + + // Then + #expect(newRegistry.allProductIds.isEmpty) + } + + // MARK: - Immutability + + @Test + func `original registry unchanged after adding marketing`() { + // Given + let original = MarketingRegistry() + + // When + let _ = original.withMarketing("com.app.pro", marketing: ProductMarketing(badge: "Pro")) + + // Then + #expect(original.allProductIds.isEmpty) // Original unchanged + } + + // MARK: - Bulk Registration from Config + + @Test + func `withMarketing from ProductDefinition`() { + // Given + let registry = MarketingRegistry() + let product = Product("com.app.yearly") + .withBadge("Best Value", color: .blue) + .withMarketingFeatures(["Cloud sync", "Premium support"]) + .withPromoText("Save 44%") + + // When + let newRegistry = registry.withMarketing(from: product) + + // Then + #expect(newRegistry.badge(for: "com.app.yearly") == "Best Value") + #expect(newRegistry.badgeColor(for: "com.app.yearly") == .blue) + #expect(newRegistry.features(for: "com.app.yearly")?.count == 2) + #expect(newRegistry.promoText(for: "com.app.yearly") == "Save 44%") + } + + @Test + func `withMarketing from multiple products`() { + // Given + let registry = MarketingRegistry() + let products = [ + Product("com.app.monthly"), + Product("com.app.yearly") + .withBadge("Best Value", color: .blue) + .withPromoText("Save 44%") + ] + + // When + let newRegistry = registry.withMarketing(from: products) + + // Then + #expect(newRegistry.allProductIds.count == 2) + #expect(newRegistry.badge(for: "com.app.yearly") == "Best Value") + } +} diff --git a/Tests/InAppKitTests/Domain/PurchaseStateTests.swift b/Tests/InAppKitTests/Domain/PurchaseStateTests.swift new file mode 100644 index 0000000..d340650 --- /dev/null +++ b/Tests/InAppKitTests/Domain/PurchaseStateTests.swift @@ -0,0 +1,141 @@ +import Testing +@testable import InAppKit + +@Suite +struct PurchaseStateTests { + + // MARK: - Creating Purchase State + + @Test + func `empty purchase state has no purchases`() { + // Given + let state = PurchaseState() + + // Then + #expect(state.purchasedProductIDs.isEmpty) + #expect(!state.hasAnyPurchase) + } + + @Test + func `purchase state can be created with initial purchases`() { + // Given + let productIds: Set = ["com.app.pro", "com.app.premium"] + + // When + let state = PurchaseState(purchasedProductIDs: productIds) + + // Then + #expect(state.purchasedProductIDs.count == 2) + #expect(state.hasAnyPurchase) + } + + // MARK: - Checking Purchases + + @Test + func `isPurchased returns true for owned product`() { + // Given + let state = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + + // Then + #expect(state.isPurchased("com.app.pro")) + } + + @Test + func `isPurchased returns false for unowned product`() { + // Given + let state = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + + // Then + #expect(!state.isPurchased("com.app.premium")) + } + + @Test + func `hasAnyPurchase is true with at least one purchase`() { + // Given + let state = PurchaseState(purchasedProductIDs: ["com.app.basic"]) + + // Then + #expect(state.hasAnyPurchase) + } + + // MARK: - Adding Purchases (Immutable) + + @Test + func `withPurchase adds a new product and returns new state`() { + // Given + let originalState = PurchaseState() + + // When + let newState = originalState.withPurchase("com.app.pro") + + // Then + #expect(!originalState.hasAnyPurchase) // Original unchanged + #expect(newState.isPurchased("com.app.pro")) + #expect(newState.hasAnyPurchase) + } + + @Test + func `withPurchases adds multiple products`() { + // Given + let originalState = PurchaseState(purchasedProductIDs: ["com.app.basic"]) + + // When + let newState = originalState.withPurchases(["com.app.pro", "com.app.premium"]) + + // Then + #expect(newState.purchasedProductIDs.count == 3) + #expect(newState.isPurchased("com.app.basic")) + #expect(newState.isPurchased("com.app.pro")) + #expect(newState.isPurchased("com.app.premium")) + } + + // MARK: - Removing Purchases (Immutable) + + @Test + func `withoutPurchase removes a product`() { + // Given + let state = PurchaseState(purchasedProductIDs: ["com.app.pro", "com.app.premium"]) + + // When + let newState = state.withoutPurchase("com.app.pro") + + // Then + #expect(!newState.isPurchased("com.app.pro")) + #expect(newState.isPurchased("com.app.premium")) + } + + @Test + func `cleared removes all purchases`() { + // Given + let state = PurchaseState(purchasedProductIDs: ["com.app.pro", "com.app.premium"]) + + // When + let newState = state.cleared() + + // Then + #expect(newState.purchasedProductIDs.isEmpty) + #expect(!newState.hasAnyPurchase) + } + + // MARK: - Equality + + @Test + func `states with same purchases are equal`() { + // Given + let state1 = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let state2 = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + + // Then + #expect(state1 == state2) + } + + @Test + func `states with different purchases are not equal`() { + // Given + let state1 = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let state2 = PurchaseState(purchasedProductIDs: ["com.app.premium"]) + + // Then + #expect(state1 != state2) + } +} diff --git a/Tests/InAppKitTests/InAppKitTests.swift b/Tests/InAppKitTests/InAppKitTests.swift index eadcb52..6cd7e1a 100644 --- a/Tests/InAppKitTests/InAppKitTests.swift +++ b/Tests/InAppKitTests/InAppKitTests.swift @@ -10,16 +10,16 @@ enum TestFeature: String, AppFeature, CaseIterable { case premium = "premium" } -// MARK: - ProductConfig Tests +// MARK: - ProductDefinition Tests -struct ProductConfigTests { +struct ProductDefinitionTests { @Test func `product without features has empty feature list`() { let product = Product("com.test.simple") #expect(product.id == "com.test.simple") #expect(product.features.isEmpty) - #expect(type(of: product) == ProductConfig.self) + #expect(type(of: product) == ProductDefinition.self) } @Test func `product with single enum feature`() { @@ -27,8 +27,8 @@ struct ProductConfigTests { #expect(product.id == "com.test.basic") #expect(product.features.count == 1) - #expect(product.features.contains(TestFeature.sync)) - #expect(type(of: product) == ProductConfig.self) + #expect(product.features.contains(AnyHashable(TestFeature.sync))) + #expect(type(of: product) == ProductDefinition.self) } @Test func `product with multiple enum features`() { @@ -36,19 +36,19 @@ struct ProductConfigTests { #expect(product.id == "com.test.pro") #expect(product.features.count == 3) - #expect(product.features.contains(TestFeature.sync)) - #expect(product.features.contains(TestFeature.export)) - #expect(product.features.contains(TestFeature.premium)) + #expect(product.features.contains(AnyHashable(TestFeature.sync))) + #expect(product.features.contains(AnyHashable(TestFeature.export))) + #expect(product.features.contains(AnyHashable(TestFeature.premium))) } @Test func `product with allCases includes all features`() { - let product = Product("com.test.premium", features: TestFeature.allCases) + let product = Product("com.test.premium", features: TestFeature.self) #expect(product.id == "com.test.premium") #expect(product.features.count == TestFeature.allCases.count) for feature in TestFeature.allCases { - #expect(product.features.contains(feature)) + #expect(product.features.contains(AnyHashable(feature))) } } @@ -57,15 +57,15 @@ struct ProductConfigTests { #expect(product.id == "com.test.string") #expect(product.features.count == 2) - #expect(product.features.contains("feature1")) - #expect(product.features.contains("feature2")) - #expect(type(of: product) == ProductConfig.self) + #expect(product.features.contains(AnyHashable("feature1"))) + #expect(product.features.contains(AnyHashable("feature2"))) + #expect(type(of: product) == ProductDefinition.self) } } -// MARK: - ProductConfig Marketing Tests +// MARK: - ProductDefinition Marketing Tests -struct ProductConfigMarketingTests { +struct ProductDefinitionMarketingTests { @Test func `product with badge`() { let product = Product("com.test.pro", features: [TestFeature.sync]) @@ -111,39 +111,39 @@ struct ProductConfigMarketingTests { } } -// MARK: - RelativeDiscountConfig Tests +// MARK: - DiscountRule Tests -struct RelativeDiscountConfigTests { +struct DiscountRuleTests { @Test func `relative discount with default percentage style`() { let product = Product("com.test.yearly", features: [TestFeature.sync]) .withRelativeDiscount(comparedTo: "com.test.monthly") - #expect(product.relativeDiscountConfig != nil) - #expect(product.relativeDiscountConfig?.baseProductId == "com.test.monthly") - #expect(product.relativeDiscountConfig?.style == .percentage) - #expect(product.relativeDiscountConfig?.color == nil) + #expect(product.discountRule != nil) + #expect(product.discountRule?.comparedTo == "com.test.monthly") + #expect(product.discountRule?.style == .percentage) + #expect(product.discountRule?.color == nil) } @Test func `relative discount with amount style`() { let product = Product("com.test.yearly", features: [TestFeature.sync]) .withRelativeDiscount(comparedTo: "com.test.monthly", style: .amount) - #expect(product.relativeDiscountConfig?.style == .amount) + #expect(product.discountRule?.style == .amount) } @Test func `relative discount with free time style`() { let product = Product("com.test.yearly", features: [TestFeature.sync]) .withRelativeDiscount(comparedTo: "com.test.monthly", style: .freeTime) - #expect(product.relativeDiscountConfig?.style == .freeTime) + #expect(product.discountRule?.style == .freeTime) } @Test func `relative discount with custom color`() { let product = Product("com.test.yearly", features: [TestFeature.sync]) .withRelativeDiscount(comparedTo: "com.test.monthly", style: .percentage, color: .green) - #expect(product.relativeDiscountConfig?.color == .green) + #expect(product.discountRule?.color == .green) } @Test func `relative discount preserves other properties`() { @@ -156,83 +156,83 @@ struct RelativeDiscountConfigTests { #expect(product.badge == "Best Value") #expect(product.promoText == "Save $44") #expect(product.marketingFeatures?.count == 2) - #expect(product.relativeDiscountConfig != nil) + #expect(product.discountRule != nil) } } -// MARK: - StoreKitConfiguration Tests +// MARK: - PurchaseSetup Tests -struct StoreKitConfigurationTests { +struct PurchaseSetupTests { @Test @MainActor func `configuration with single product id`() { - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro") - #expect(config.productConfigs.count == 1) - #expect(config.productConfigs.first?.id == "com.test.pro") + #expect(config.products.count == 1) + #expect(config.products.first?.id == "com.test.pro") } @Test @MainActor func `configuration with variadic product ids`() { - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro1", "com.test.pro2", "com.test.pro3") - #expect(config.productConfigs.count == 3) - #expect(config.productConfigs[0].id == "com.test.pro1") - #expect(config.productConfigs[1].id == "com.test.pro2") - #expect(config.productConfigs[2].id == "com.test.pro3") + #expect(config.products.count == 3) + #expect(config.products[0].id == "com.test.pro1") + #expect(config.products[1].id == "com.test.pro2") + #expect(config.products[2].id == "com.test.pro3") } @Test @MainActor func `configuration with product array`() { - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases(products: [ Product("com.test.pro", features: [TestFeature.sync, TestFeature.export]), Product("com.test.premium", features: [TestFeature.premium]) ]) - #expect(config.productConfigs.count == 2) - #expect(config.productConfigs[0].id == "com.test.pro") - #expect(config.productConfigs[1].id == "com.test.premium") + #expect(config.products.count == 2) + #expect(config.products[0].id == "com.test.pro") + #expect(config.products[1].id == "com.test.premium") } @Test @MainActor func `configuration with mixed product types`() { - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases(products: [ Product("com.test.basic", features: [TestFeature.sync, TestFeature.export, TestFeature.premium]), - Product("com.test.premium", features: TestFeature.allCases), + Product("com.test.premium", features: TestFeature.self), Product("com.test.premium1", features: ["some-feature"]), Product("com.test.basic1") ]) - #expect(config.productConfigs.count == 4) - #expect(config.productConfigs[0].features.count == 3) - #expect(config.productConfigs[1].features.count == 3) - #expect(config.productConfigs[2].features.count == 1) - #expect(config.productConfigs[3].features.isEmpty) + #expect(config.products.count == 4) + #expect(config.products[0].features.count == 3) + #expect(config.products[1].features.count == 3) + #expect(config.products[2].features.count == 1) + #expect(config.products[3].features.isEmpty) } @Test @MainActor func `configuration with relative discount`() { - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases(products: [ Product("com.test.monthly", features: [TestFeature.sync]), Product("com.test.yearly", features: [TestFeature.sync]) .withRelativeDiscount(comparedTo: "com.test.monthly") ]) - #expect(config.productConfigs.count == 2) - #expect(config.productConfigs[0].relativeDiscountConfig == nil) - #expect(config.productConfigs[1].relativeDiscountConfig != nil) - #expect(config.productConfigs[1].relativeDiscountConfig?.baseProductId == "com.test.monthly") + #expect(config.products.count == 2) + #expect(config.products[0].discountRule == nil) + #expect(config.products[1].discountRule != nil) + #expect(config.products[1].discountRule?.comparedTo == "com.test.monthly") } } -// MARK: - StoreKitConfiguration Paywall Tests +// MARK: - PurchaseSetup Paywall Tests -struct StoreKitConfigurationPaywallTests { +struct PurchaseSetupPaywallTests { @Test @MainActor func `configuration with custom paywall builder`() { var paywallCalled = false - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro") .withPaywall { context in paywallCalled = true @@ -249,7 +249,7 @@ struct StoreKitConfigurationPaywallTests { @Test @MainActor func `configuration with terms builder`() { var termsCalled = false - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro") .withTerms { termsCalled = true @@ -264,7 +264,7 @@ struct StoreKitConfigurationPaywallTests { @Test @MainActor func `configuration with privacy builder`() { var privacyCalled = false - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro") .withPrivacy { privacyCalled = true @@ -279,7 +279,7 @@ struct StoreKitConfigurationPaywallTests { @Test @MainActor func `configuration with terms URL`() { let termsURL = URL(string: "https://example.com/terms")! - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro") .withTerms(url: termsURL) @@ -289,7 +289,7 @@ struct StoreKitConfigurationPaywallTests { @Test @MainActor func `configuration with privacy URL`() { let privacyURL = URL(string: "https://example.com/privacy")! - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro") .withPrivacy(url: privacyURL) @@ -300,7 +300,7 @@ struct StoreKitConfigurationPaywallTests { let termsURL = URL(string: "https://example.com/terms")! let privacyURL = URL(string: "https://example.com/privacy")! - let config = StoreKitConfiguration() + let config = PurchaseSetup() .withPurchases("com.test.pro") .withTerms(url: termsURL) .withPrivacy(url: privacyURL) @@ -551,10 +551,10 @@ struct InAppKitMarketingInfoTests { #expect(manager.promoText(for: "com.test.unknown") == nil) } - @Test @MainActor func `relativeDiscountConfig returns nil for unconfigured product`() { + @Test @MainActor func `discountRule returns nil for unconfigured product`() { let manager = InAppKit.shared - #expect(manager.relativeDiscountConfig(for: "com.test.unknown") == nil) + #expect(manager.discountRule(for: "com.test.unknown") == nil) } } @@ -603,63 +603,54 @@ struct StoreErrorTests { } } -// MARK: - InternalProductConfig Tests +// MARK: - ProductDefinition Feature Tests -struct InternalProductConfigTests { +struct ProductDefinitionFeatureTests { - @Test func `toInternal converts ProductConfig with all properties`() { + @Test func `ProductDefinition with all properties`() { let product = Product("com.test.yearly", features: [TestFeature.sync, TestFeature.export]) .withBadge("Best Value", color: .blue) .withPromoText("Save 44%") .withMarketingFeatures(["Cloud sync", "Premium support"]) .withRelativeDiscount(comparedTo: "com.test.monthly", style: .percentage, color: .green) - let config = product.toInternal() - - #expect(config.id == "com.test.yearly") - #expect(config.features.count == 2) - #expect(config.badge == "Best Value") - #expect(config.badgeColor == .blue) - #expect(config.promoText == "Save 44%") - #expect(config.marketingFeatures?.count == 2) - #expect(config.relativeDiscountConfig?.baseProductId == "com.test.monthly") - #expect(config.relativeDiscountConfig?.style == .percentage) - #expect(config.relativeDiscountConfig?.color == .green) + #expect(product.id == "com.test.yearly") + #expect(product.features.count == 2) + #expect(product.badge == "Best Value") + #expect(product.badgeColor == .blue) + #expect(product.promoText == "Save 44%") + #expect(product.marketingFeatures?.count == 2) + #expect(product.discountRule?.comparedTo == "com.test.monthly") + #expect(product.discountRule?.style == .percentage) + #expect(product.discountRule?.color == .green) } - @Test func `toInternal converts ProductConfig with minimal properties`() { + @Test func `ProductDefinition with minimal properties`() { let product = Product("com.test.basic") - let config = product.toInternal() - - #expect(config.id == "com.test.basic") - #expect(config.features.isEmpty) - #expect(config.badge == nil) - #expect(config.badgeColor == nil) - #expect(config.promoText == nil) - #expect(config.marketingFeatures == nil) - #expect(config.relativeDiscountConfig == nil) + #expect(product.id == "com.test.basic") + #expect(product.features.isEmpty) + #expect(product.badge == nil) + #expect(product.badgeColor == nil) + #expect(product.promoText == nil) + #expect(product.marketingFeatures == nil) + #expect(product.discountRule == nil) } - @Test func `toInternal converts features to AnyHashable`() { + @Test func `ProductDefinition stores features as AnyHashable`() { let product = Product("com.test.pro", features: [TestFeature.sync, TestFeature.premium]) - let config = product.toInternal() - - // Features should be converted to AnyHashable - #expect(config.features.count == 2) - #expect(config.features.contains(AnyHashable(TestFeature.sync))) - #expect(config.features.contains(AnyHashable(TestFeature.premium))) + #expect(product.features.count == 2) + #expect(product.features.contains(AnyHashable(TestFeature.sync))) + #expect(product.features.contains(AnyHashable(TestFeature.premium))) } - @Test func `toInternal with string features`() { + @Test func `ProductDefinition with string features`() { let product = Product("com.test.string", features: ["feature_a", "feature_b"]) - let config = product.toInternal() - - #expect(config.features.count == 2) - #expect(config.features.contains(AnyHashable("feature_a"))) - #expect(config.features.contains(AnyHashable("feature_b"))) + #expect(product.features.count == 2) + #expect(product.features.contains(AnyHashable("feature_a"))) + #expect(product.features.contains(AnyHashable("feature_b"))) } } @@ -697,9 +688,9 @@ struct ViewExtensionTests { } } -// MARK: - ChainableStoreKitView Tests +// MARK: - PurchaseEnabledView Tests -struct ChainableStoreKitViewTests { +struct PurchaseEnabledViewTests { @Test @MainActor func `chained view has correct type`() { let baseView = Text("Test Content") @@ -718,7 +709,7 @@ struct ChainableStoreKitViewTests { Text("Custom Privacy") } - #expect(type(of: chainedView) == ChainableStoreKitView.self) + #expect(type(of: chainedView) == PurchaseEnabledView.self) } @Test @MainActor func `chained view preserves configuration`() { @@ -732,8 +723,8 @@ struct ChainableStoreKitViewTests { .withTerms { Text("Terms") } .withPrivacy { Text("Privacy") } - #expect(chainedView.config.productConfigs.count == 1) - #expect(chainedView.config.productConfigs.first?.id == "com.test.pro") + #expect(chainedView.config.products.count == 1) + #expect(chainedView.config.products.first?.id == "com.test.pro") #expect(chainedView.config.paywallBuilder != nil) #expect(chainedView.config.termsBuilder != nil) #expect(chainedView.config.privacyBuilder != nil) @@ -749,7 +740,7 @@ struct ChainableStoreKitViewTests { .withTerms { Text("Custom Terms") } .withPrivacy { Text("Custom Privacy") } - #expect(chainedView.config.productConfigs.count == 1) + #expect(chainedView.config.products.count == 1) #expect(chainedView.config.paywallBuilder == nil) #expect(chainedView.config.termsBuilder != nil) #expect(chainedView.config.privacyBuilder != nil) @@ -762,9 +753,9 @@ struct ChainableStoreKitViewTests { .withPurchases("com.test.pro") .withPaywall { _ in Text("Simple Paywall") } - #expect(type(of: chainedView) == ChainableStoreKitView.self) - #expect(chainedView.config.productConfigs.count == 1) - #expect(chainedView.config.productConfigs.first?.id == "com.test.pro") + #expect(type(of: chainedView) == PurchaseEnabledView.self) + #expect(chainedView.config.products.count == 1) + #expect(chainedView.config.products.first?.id == "com.test.pro") } @Test @MainActor func `chained view with relative discount`() { @@ -778,8 +769,8 @@ struct ChainableStoreKitViewTests { ]) .withPaywall { _ in Text("Upgrade Now") } - #expect(chainedView.config.productConfigs.count == 2) - #expect(chainedView.config.productConfigs[1].relativeDiscountConfig?.style == .percentage) + #expect(chainedView.config.products.count == 2) + #expect(chainedView.config.products[1].discountRule?.style == .percentage) } @Test @MainActor func `chained view with URL-based terms and privacy`() { diff --git a/Tests/InAppKitTests/Infrastructure/AppStoreTests.swift b/Tests/InAppKitTests/Infrastructure/AppStoreTests.swift new file mode 100644 index 0000000..5277b16 --- /dev/null +++ b/Tests/InAppKitTests/Infrastructure/AppStoreTests.swift @@ -0,0 +1,139 @@ +import Testing +import StoreKit +import Mockable +@testable import InAppKit + +@Suite +struct AppStoreTests { + + // MARK: - Products + + @Test + func `products fetches from provider`() async throws { + // Given + let mockProvider = MockStoreKitProvider() + + given(mockProvider) + .fetchProducts(for: .any) + .willReturn([]) + + let appStore = AppStore(provider: mockProvider) + + // When + let products = try await appStore.products(for: ["com.app.pro"]) + + // Then + await verify(mockProvider) + .fetchProducts(for: .value(Set(["com.app.pro"]))) + .called(.once) + + #expect(products.isEmpty) + } + + @Test + func `products throws when provider throws`() async { + // Given + let mockProvider = MockStoreKitProvider() + + given(mockProvider) + .fetchProducts(for: .any) + .willThrow(StoreError.networkError(NSError(domain: "test", code: -1))) + + let appStore = AppStore(provider: mockProvider) + + // When/Then + do { + _ = try await appStore.products(for: ["com.app.pro"]) + Issue.record("Expected error to be thrown") + } catch { + // Expected + } + } + + // MARK: - Purchases (Entitlements) + + @Test + func `purchases returns empty when no entitlements`() async throws { + // Given + let mockProvider = MockStoreKitProvider() + + given(mockProvider) + .currentEntitlements() + .willReturn([]) + + let appStore = AppStore(provider: mockProvider) + + // When + let purchases = try await appStore.purchases() + + // Then + #expect(purchases.isEmpty) + } + + // MARK: - Restore + + @Test + func `restore calls sync then fetches entitlements`() async throws { + // Given + let mockProvider = MockStoreKitProvider() + + given(mockProvider) + .sync() + .willReturn(()) + + given(mockProvider) + .currentEntitlements() + .willReturn([]) + + let appStore = AppStore(provider: mockProvider) + + // When + let restored = try await appStore.restore() + + // Then + await verify(mockProvider) + .sync() + .called(.once) + + await verify(mockProvider) + .currentEntitlements() + .called(.once) + + #expect(restored.isEmpty) + } + + @Test + func `restore throws when sync fails`() async { + // Given + let mockProvider = MockStoreKitProvider() + + given(mockProvider) + .sync() + .willThrow(StoreError.networkError(NSError(domain: "test", code: -1))) + + let appStore = AppStore(provider: mockProvider) + + // When/Then + do { + _ = try await appStore.restore() + Issue.record("Expected error to be thrown") + } catch { + // Expected - sync failed + } + } +} + +// MARK: - StoreKitProvider Protocol Tests + +@Suite +struct StoreKitProviderTests { + + @Test + func `DefaultStoreKitProvider can be instantiated`() { + // Given/When + let provider = DefaultStoreKitProvider() + + // Then - no crash, provider exists + #expect(provider != nil) + } +} diff --git a/Tests/InAppKitTests/Infrastructure/StoreTests.swift b/Tests/InAppKitTests/Infrastructure/StoreTests.swift new file mode 100644 index 0000000..43df5a0 --- /dev/null +++ b/Tests/InAppKitTests/Infrastructure/StoreTests.swift @@ -0,0 +1,144 @@ +import Testing +import StoreKit +import Mockable +@testable import InAppKit + +@Suite +struct StoreTests { + + // MARK: - InAppKit with MockStore + + @Test @MainActor + func `InAppKit can be created with mock store`() async { + // Given + let mockStore = MockStore() + + // Configure mock to return empty purchases + given(mockStore) + .purchases() + .willReturn(Set()) + + // When + let inAppKit = InAppKit.configure(with: mockStore) + + // Then - InAppKit created successfully + #expect(inAppKit.purchasedProductIDs.isEmpty) + } + + @Test @MainActor + func `loadProducts calls store products`() async { + // Given + let mockStore = MockStore() + + given(mockStore) + .purchases() + .willReturn(Set()) + + given(mockStore) + .products(for: .any) + .willReturn([]) + + let inAppKit = InAppKit.configure(with: mockStore) + + // When + await inAppKit.loadProducts(productIds: ["com.app.pro"]) + + // Then + await verify(mockStore) + .products(for: .value(Set(["com.app.pro"]))) + .called(.atLeastOnce) + } + + @Test @MainActor + func `restorePurchases calls store restore`() async { + // Given + let mockStore = MockStore() + + given(mockStore) + .purchases() + .willReturn(Set()) + + given(mockStore) + .restore() + .willReturn(Set(["com.app.pro"])) + + let inAppKit = InAppKit.configure(with: mockStore) + + // When + await inAppKit.restorePurchases() + + // Then + #expect(inAppKit.isPurchased("com.app.pro")) + await verify(mockStore) + .restore() + .called(.once) + } + + @Test @MainActor + func `hasAccess returns true after store returns purchases`() async { + // Given + let mockStore = MockStore() + + given(mockStore) + .purchases() + .willReturn(Set(["com.app.pro"])) + + let inAppKit = InAppKit.configure(with: mockStore) + inAppKit.registerFeature("premium", productIds: ["com.app.pro"]) + + // Wait for initial purchase refresh + try? await Task.sleep(for: .milliseconds(100)) + + // Then + #expect(inAppKit.hasAccess(to: "premium")) + } + + @Test @MainActor + func `hasAccess returns false when store returns no purchases`() async { + // Given + let mockStore = MockStore() + + given(mockStore) + .purchases() + .willReturn(Set()) + + let inAppKit = InAppKit.configure(with: mockStore) + inAppKit.registerFeature("premium", productIds: ["com.app.pro"]) + + // Wait for initial purchase refresh + try? await Task.sleep(for: .milliseconds(100)) + + // Then + #expect(!inAppKit.hasAccess(to: "premium")) + } +} + +// MARK: - Store Protocol Tests + +@Suite +struct StoreProtocolTests { + + @Test + func `PurchaseOutcome success is equatable`() { + // Given + let outcome1 = PurchaseOutcome.success(productId: "com.app.pro") + let outcome2 = PurchaseOutcome.success(productId: "com.app.pro") + let outcome3 = PurchaseOutcome.success(productId: "com.app.premium") + + // Then + #expect(outcome1 == outcome2) + #expect(outcome1 != outcome3) + } + + @Test + func `PurchaseOutcome cancelled is equatable`() { + #expect(PurchaseOutcome.cancelled == PurchaseOutcome.cancelled) + #expect(PurchaseOutcome.cancelled != PurchaseOutcome.pending) + } + + @Test + func `PurchaseOutcome pending is equatable`() { + #expect(PurchaseOutcome.pending == PurchaseOutcome.pending) + #expect(PurchaseOutcome.pending != PurchaseOutcome.cancelled) + } +} diff --git a/docs/api-reference.md b/docs/api-reference.md index 7482632..9a1c913 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -4,13 +4,45 @@ ## 📖 Table of Contents +- [Architecture Overview](#architecture-overview) - [Product Functions](#product-functions) - [Configuration](#configuration) - [View Modifiers](#view-modifiers) - [InAppKit Core](#inappkit-core) +- [Domain Models](#domain-models) - [Types & Protocols](#types--protocols) +- [Testing](#testing) - [Troubleshooting](#troubleshooting) +## Architecture Overview + +InAppKit follows **Domain-Driven Design** with clear separation between domain logic and infrastructure. + +``` +Sources/InAppKit/ +├── Core/ +│ ├── Domain/ ← Pure business logic (100% testable) +│ │ ├── ProductDefinition.swift +│ │ ├── DiscountRule.swift +│ │ ├── PurchaseState.swift +│ │ ├── FeatureRegistry.swift +│ │ ├── AccessControl.swift +│ │ ├── MarketingRegistry.swift +│ │ └── Store.swift (protocol) +│ └── InAppKit.swift ← Main coordinator +├── Infrastructure/ ← StoreKit integration +│ ├── AppStore.swift +│ └── StoreKitProvider.swift +├── Modifiers/ ← SwiftUI integration +│ └── PurchaseSetup.swift +└── UI/ ← UI components +``` + +**Key Principles:** +- Domain models are pure, with no StoreKit dependencies +- Infrastructure implements domain protocols +- InAppKit delegates to domain models for business logic + ## Product Functions ### Product Creation @@ -19,13 +51,13 @@ All Product functions follow a consistent pattern: *Need features? Use `features ```swift // No features -public func Product(_ id: String) -> ProductConfig +public func Product(_ id: String) -> ProductDefinition // With features array -public func Product(_ id: String, features: [T]) -> ProductConfig +public func Product(_ id: String, features: [T]) -> ProductDefinition // With allCases (for CaseIterable enums) -public func Product(_ id: String, features: T.AllCases) -> ProductConfig +public func Product(_ id: String, features: T.AllCases) -> ProductDefinition ``` #### Examples @@ -47,16 +79,16 @@ Product("com.app.custom", features: ["feature1", "feature2"]) ### Marketing Extensions ```swift -extension ProductConfig { - func withBadge(_ badge: String) -> ProductConfig - func withBadge(_ badge: String, color: Color) -> ProductConfig - func withMarketingFeatures(_ features: [String]) -> ProductConfig - func withPromoText(_ text: String) -> ProductConfig +extension ProductDefinition { + func withBadge(_ badge: String) -> ProductDefinition + func withBadge(_ badge: String, color: Color) -> ProductDefinition + func withMarketingFeatures(_ features: [String]) -> ProductDefinition + func withPromoText(_ text: String) -> ProductDefinition func withRelativeDiscount( comparedTo baseProductId: String, - style: RelativeDiscountConfig.DiscountStyle = .percentage, + style: DiscountRule.Style = .percentage, color: Color? = nil - ) -> ProductConfig + ) -> ProductDefinition } ``` @@ -107,28 +139,32 @@ Product("com.app.yearly", features: features) ## Configuration -### StoreKitConfiguration +### PurchaseSetup ```swift -public class StoreKitConfiguration { +public class PurchaseSetup { public init() // Product configuration - public func withPurchases(_ productId: String) -> StoreKitConfiguration - public func withPurchases(_ productIds: String...) -> StoreKitConfiguration - public func withPurchases(products: [ProductConfig]) -> StoreKitConfiguration + public func withPurchases(_ productId: String) -> PurchaseSetup + public func withPurchases(_ productIds: String...) -> PurchaseSetup + public func withPurchases(products: [ProductDefinition]) -> PurchaseSetup // UI configuration - public func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> Content) -> StoreKitConfiguration - public func withTerms(@ViewBuilder _ builder: @escaping () -> Content) -> StoreKitConfiguration - public func withPrivacy(@ViewBuilder _ builder: @escaping () -> Content) -> StoreKitConfiguration + public func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> Content) -> PurchaseSetup + public func withPaywallHeader(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup + public func withPaywallFeatures(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup + public func withTerms(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup + public func withTerms(url: URL) -> PurchaseSetup + public func withPrivacy(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseSetup + public func withPrivacy(url: URL) -> PurchaseSetup } ``` #### Example ```swift -let config = StoreKitConfiguration() +let config = PurchaseSetup() .withPurchases(products: [ Product("com.app.basic", features: [Feature.removeAds]), Product("com.app.pro", features: Feature.allCases) @@ -175,22 +211,23 @@ extension View { ```swift extension View { // Direct configuration - func withPurchases(_ productId: String) -> ChainableStoreKitView - func withPurchases(_ productIds: String...) -> ChainableStoreKitView - func withPurchases(products: [ProductConfig]) -> ChainableStoreKitView - - // Full configuration - func withConfiguration(_ config: StoreKitConfiguration) -> some View + func withPurchases(_ productId: String) -> PurchaseEnabledView + func withPurchases(_ productIds: String...) -> PurchaseEnabledView + func withPurchases(products: [ProductDefinition]) -> PurchaseEnabledView } ``` ### Chained Configuration ```swift -extension ChainableStoreKitView { - func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> Content) -> ChainableStoreKitView - func withTerms(@ViewBuilder _ builder: @escaping () -> Content) -> ChainableStoreKitView - func withPrivacy(@ViewBuilder _ builder: @escaping () -> Content) -> ChainableStoreKitView +extension PurchaseEnabledView { + func withPaywall(@ViewBuilder _ builder: @escaping (PaywallContext) -> Content) -> PurchaseEnabledView + func withPaywallHeader(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseEnabledView + func withPaywallFeatures(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseEnabledView + func withTerms(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseEnabledView + func withTerms(url: URL) -> PurchaseEnabledView + func withPrivacy(@ViewBuilder _ builder: @escaping () -> Content) -> PurchaseEnabledView + func withPrivacy(url: URL) -> PurchaseEnabledView } ``` @@ -288,54 +325,231 @@ enum MyAppFeature: String, AppFeature { } ``` -### ProductConfig +### ProductDefinition ```swift -public struct ProductConfig: Sendable { +public struct ProductDefinition: AnyProductDefinition { public let id: String - public let features: [T] + public let features: [Feature] public let badge: String? + public let badgeColor: Color? public let marketingFeatures: [String]? - public let savings: String? + public let promoText: String? + public let discountRule: DiscountRule? public init( _ id: String, - features: [T], + features: [Feature], badge: String? = nil, + badgeColor: Color? = nil, marketingFeatures: [String]? = nil, - savings: String? = nil + promoText: String? = nil, + discountRule: DiscountRule? = nil ) } ``` +### DiscountRule + +```swift +public struct DiscountRule: Sendable { + public let comparedTo: String // base product ID + public let style: Style + public let color: Color? + + public init(comparedTo baseProductId: String, style: Style = .percentage, color: Color? = nil) + + public enum Style: Sendable { + case percentage // "31% off" + case amount // "Save $44" + case freeTime // "2 months free" + } +} +``` + ### PaywallContext ```swift public struct PaywallContext { public let triggeredBy: String? - public let availableProducts: [StoreKit.Product] - public let recommendedProduct: StoreKit.Product? + public let availableProducts: [Product] + public let recommendedProduct: Product? // Marketing helpers - @MainActor public func badge(for product: StoreKit.Product) -> String? - @MainActor public func marketingFeatures(for product: StoreKit.Product) -> [String]? - @MainActor public func savings(for product: StoreKit.Product) -> String? - @MainActor public func marketingInfo(for product: StoreKit.Product) -> (badge: String?, features: [String]?, savings: String?) - @MainActor public var productsWithMarketing: [(product: StoreKit.Product, badge: String?, features: [String]?, savings: String?)] + @MainActor public func badge(for product: Product) -> String? + @MainActor public func marketingFeatures(for product: Product) -> [String]? + @MainActor public func promoText(for product: Product) -> String? + @MainActor public func marketingInfo(for product: Product) -> (badge: String?, features: [String]?, promoText: String?) + @MainActor public var productsWithMarketing: [(product: Product, badge: String?, features: [String]?, promoText: String?)] } ``` -### ChainableStoreKitView +### PurchaseEnabledView ```swift -public struct ChainableStoreKitView: View { - public let wrappedView: WrappedView - public let config: StoreKitConfiguration +public struct PurchaseEnabledView: View { + let content: Content + let config: PurchaseSetup public var body: some View } ``` +## Domain Models + +Pure domain models with no StoreKit dependencies. 100% testable without mocks. + +### PurchaseState + +Immutable value type tracking what the user has purchased. + +```swift +public struct PurchaseState: Equatable, Sendable { + public private(set) var purchasedProductIDs: Set + + public var hasAnyPurchase: Bool + public func isPurchased(_ productId: String) -> Bool + + // Immutable updates + public func withPurchase(_ productId: String) -> PurchaseState + public func withoutPurchase(_ productId: String) -> PurchaseState + public func cleared() -> PurchaseState +} +``` + +### FeatureRegistry + +Maps features to products that unlock them. + +```swift +public struct FeatureRegistry: Equatable { + public func isRegistered(_ feature: AnyHashable) -> Bool + public func productIds(for feature: AnyHashable) -> Set + public func features(unlockedBy productId: String) -> Set + + // Immutable updates + public func withFeature(_ feature: AnyHashable, productIds: [String]) -> FeatureRegistry + public func withoutFeature(_ feature: AnyHashable) -> FeatureRegistry +} +``` + +### AccessControl + +Pure functions for access control decisions. + +```swift +public enum AccessControl { + public static func hasAccess( + to feature: AnyHashable, + purchaseState: PurchaseState, + featureRegistry: FeatureRegistry + ) -> Bool + + public static func accessibleFeatures( + purchaseState: PurchaseState, + featureRegistry: FeatureRegistry + ) -> Set +} +``` + +### MarketingRegistry + +Stores marketing information for products. + +```swift +public struct MarketingRegistry { + public func badge(for productId: String) -> String? + public func badgeColor(for productId: String) -> Color? + public func features(for productId: String) -> [String]? + public func promoText(for productId: String) -> String? + public func relativeDiscountConfig(for productId: String) -> DiscountRule? + + // Immutable updates + public func withMarketing(_ productId: String, marketing: ProductMarketing) -> MarketingRegistry + public func withMarketing(from config: InternalProductConfig) -> MarketingRegistry +} +``` + +### Store Protocol + +Protocol for store operations (implements `@Mockable` for testing). + +```swift +@Mockable +public protocol Store: Sendable { + func products(for ids: Set) async throws -> [Product] + func purchase(_ product: Product) async throws -> PurchaseOutcome + func purchases() async throws -> Set + func restore() async throws -> Set +} + +public enum PurchaseOutcome: Sendable { + case success + case cancelled + case pending +} +``` + +## Testing + +### Domain Tests (Pure, No Mocks) + +Domain models are fully testable without mocks: + +```swift +@Test func `user with correct purchase has access to feature`() { + // Given + let purchaseState = PurchaseState(purchasedProductIDs: ["com.app.pro"]) + let registry = FeatureRegistry().withFeature("sync", productIds: ["com.app.pro"]) + + // When + let hasAccess = AccessControl.hasAccess( + to: "sync", + purchaseState: purchaseState, + featureRegistry: registry + ) + + // Then + #expect(hasAccess) +} +``` + +### Infrastructure Tests (With Mockable) + +Infrastructure tests use auto-generated mocks: + +```swift +@Test func `loadProducts calls store`() async { + // Given + let mockStore = MockStore() + given(mockStore).products(for: .any).willReturn([]) + + let inAppKit = InAppKit.configure(with: mockStore) + + // When + await inAppKit.loadProducts(productIds: ["com.app.pro"]) + + // Then + await verify(mockStore).products(for: .value(Set(["com.app.pro"]))).called(.once) +} +``` + +### Debug Helpers + +```swift +#if DEBUG +// Simulate purchases for testing +InAppKit.shared.simulatePurchase("com.app.pro") + +// Clear purchases +InAppKit.shared.clearPurchases() + +// Clear registries +InAppKit.shared.clearFeatures() +InAppKit.shared.clearMarketing() +#endif +``` + ## Troubleshooting ### Common Issues diff --git a/docs/customization.md b/docs/customization.md index c4296c1..ddc6379 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -151,7 +151,7 @@ struct PaywallView: View { product: product, badge: context.badge(for: product), features: context.marketingFeatures(for: product), - savings: context.savings(for: product) + promoText: context.promoText(for: product) ) } } @@ -162,7 +162,7 @@ struct ProductCard: View { let product: Product let badge: String? let features: [String]? - let savings: String? + let promoText: String? var body: some View { VStack(alignment: .leading) { @@ -196,8 +196,8 @@ struct ProductCard: View { .font(.title2) .fontWeight(.bold) - if let savings = savings { - Text(savings) + if let promoText = promoText { + Text(promoText) .font(.caption) .foregroundColor(.orange) } @@ -309,10 +309,10 @@ struct FeatureGateView: View { ## Product Configuration -### Configuration with StoreKitConfiguration +### Configuration with PurchaseSetup ```swift -let config = StoreKitConfiguration() +let config = PurchaseSetup() .withPurchases(products: [ Product("com.app.basic", features: [Feature.removeAds]), Product("com.app.pro", features: [Feature.removeAds, Feature.cloudSync]) @@ -347,7 +347,7 @@ struct ContentView: View { } } - private var products: [ProductConfig] { + private var products: [ProductDefinition] { #if DEBUG return [ Product("com.app.test", features: AppFeature.allCases) @@ -646,7 +646,7 @@ struct AdvancedView: View { ```swift struct DynamicConfigView: View { - @State private var products: [ProductConfig] = [] + @State private var products: [ProductDefinition] = [] var body: some View { ContentView()