Fix #19: FeatureGate-Abstraktion + umfassende Abo-Tests
Kapselt die gesamte Abo-Logik in einem testbaren `FeatureGate` Value-Type. `refreshStatus()` nutzt nun `FeatureGate.from(foundPro:foundMax:)` als zentrale Factory — die "Max implies Pro"-Invariante ist so nicht mehr implizit im StoreManager vergraben, sondern explizit und durch 18 neue Unit-Tests abgesichert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,49 @@ import StoreKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Feature Gate
|
||||||
|
|
||||||
|
/// Reiner Value-Type, der den Zugriff auf alle abonnierten Features kapselt.
|
||||||
|
/// Kann ohne StoreKit in Unit-Tests instanziiert werden.
|
||||||
|
struct FeatureGate: Equatable {
|
||||||
|
let isPro: Bool
|
||||||
|
let isMax: Bool
|
||||||
|
|
||||||
|
// MARK: Pro-Features (freigeschaltet durch Pro ODER Max)
|
||||||
|
var unlimitedContacts: Bool { isPro }
|
||||||
|
var premiumThemes: Bool { isPro }
|
||||||
|
var shareExtension: Bool { isPro }
|
||||||
|
|
||||||
|
// MARK: Max-Features (nur durch Max)
|
||||||
|
var aiAnalysis: Bool { isMax }
|
||||||
|
var giftSuggestions: Bool { isMax }
|
||||||
|
var conversationTopics: Bool { isMax }
|
||||||
|
var unlimitedAIQueries: Bool { isMax }
|
||||||
|
|
||||||
|
/// Invariante: Max schließt immer alle Pro-Features ein.
|
||||||
|
var isConsistent: Bool { !isMax || isPro }
|
||||||
|
|
||||||
|
/// Erstellt FeatureGate aus dem Ergebnis eines Transaktions-Scans.
|
||||||
|
/// Max setzt automatisch auch isPro = true.
|
||||||
|
static func from(foundPro: Bool, foundMax: Bool) -> FeatureGate {
|
||||||
|
FeatureGate(isPro: foundPro || foundMax, isMax: foundMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let free = FeatureGate(isPro: false, isMax: false)
|
||||||
|
|
||||||
|
// MARK: - Hilfsfunktionen (Pure Logic, testbar)
|
||||||
|
|
||||||
|
/// Darf die KI genutzt werden? Max-Abonnenten immer, sonst nur wenn noch Gratis-Abfragen übrig.
|
||||||
|
static func canUseAI(isMax: Bool, hasFreeQueriesLeft: Bool) -> Bool {
|
||||||
|
isMax || hasFreeQueriesLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soll eine Gratis-Abfrage verbraucht werden? Nur bei Nicht-Max-Abonnenten.
|
||||||
|
static func shouldConsumeFreeQuery(isMax: Bool) -> Bool {
|
||||||
|
!isMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Subscription Tier
|
// MARK: - Subscription Tier
|
||||||
|
|
||||||
enum SubscriptionTier: CaseIterable {
|
enum SubscriptionTier: CaseIterable {
|
||||||
@@ -40,6 +83,9 @@ class StoreManager: ObservableObject {
|
|||||||
/// Rückwärtskompatibilität für bestehende Aufrufstellen
|
/// Rückwärtskompatibilität für bestehende Aufrufstellen
|
||||||
var product: Product? { proProduct }
|
var product: Product? { proProduct }
|
||||||
|
|
||||||
|
/// Aktueller Feature-Zustand als testbarer Value-Type.
|
||||||
|
var features: FeatureGate { FeatureGate(isPro: isPro, isMax: isMax) }
|
||||||
|
|
||||||
private var transactionListenerTask: Task<Void, Never>? = nil
|
private var transactionListenerTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
@@ -119,8 +165,9 @@ class StoreManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isMax = foundMax
|
let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax)
|
||||||
isPro = foundPro || foundMax // Max schließt alle Pro-Features ein
|
isMax = gate.isMax
|
||||||
|
isPro = gate.isPro
|
||||||
AppGroup.saveProStatus(isPro)
|
AppGroup.saveProStatus(isPro)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,165 @@ struct AppGroupProStatusTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate Consistency Tests
|
||||||
|
|
||||||
|
/// Stellt sicher, dass die Invariante "Max implies Pro" unter allen Transaktions-Kombinationen gilt.
|
||||||
|
@Suite("FeatureGate – Konsistenz")
|
||||||
|
struct FeatureGateConsistencyTests {
|
||||||
|
|
||||||
|
@Test("free: isPro und isMax beide false")
|
||||||
|
func freeHasNoAccess() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: false)
|
||||||
|
#expect(gate.isPro == false)
|
||||||
|
#expect(gate.isMax == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro-only: isPro true, isMax false")
|
||||||
|
func proOnlySetsPro() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: false)
|
||||||
|
#expect(gate.isPro == true)
|
||||||
|
#expect(gate.isMax == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Max-only: isMax true setzt isPro automatisch true (Max implies Pro)")
|
||||||
|
func maxOnlyImpliesPro() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: true)
|
||||||
|
#expect(gate.isMax == true)
|
||||||
|
#expect(gate.isPro == true, "Max-Abonnenten müssen auch Pro-Features erhalten")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro + Max: beide true")
|
||||||
|
func proAndMaxBothTrue() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: true)
|
||||||
|
#expect(gate.isPro == true)
|
||||||
|
#expect(gate.isMax == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isConsistent gilt für alle 4 Transaktions-Kombinationen")
|
||||||
|
func consistencyHoldsForAllCombinations() {
|
||||||
|
let combinations = [(false, false), (true, false), (false, true), (true, true)]
|
||||||
|
for (foundPro, foundMax) in combinations {
|
||||||
|
let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax)
|
||||||
|
#expect(gate.isConsistent, "Invariante verletzt für foundPro=\(foundPro), foundMax=\(foundMax)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FeatureGate.free entspricht from(false, false)")
|
||||||
|
func freeStaticMatchesFromFactory() {
|
||||||
|
#expect(FeatureGate.free == FeatureGate.from(foundPro: false, foundMax: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate Access Tests
|
||||||
|
|
||||||
|
/// Verifiziert, dass jedes Feature genau für den richtigen Tier freigeschaltet wird.
|
||||||
|
@Suite("FeatureGate – Feature-Zugriff")
|
||||||
|
struct FeatureGateAccessTests {
|
||||||
|
|
||||||
|
// MARK: Free-User
|
||||||
|
|
||||||
|
@Test("Free-User: keine Pro-Features")
|
||||||
|
func freeUserHasNoProFeatures() {
|
||||||
|
let gate = FeatureGate.free
|
||||||
|
#expect(!gate.unlimitedContacts)
|
||||||
|
#expect(!gate.premiumThemes)
|
||||||
|
#expect(!gate.shareExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Free-User: keine Max-Features")
|
||||||
|
func freeUserHasNoMaxFeatures() {
|
||||||
|
let gate = FeatureGate.free
|
||||||
|
#expect(!gate.aiAnalysis)
|
||||||
|
#expect(!gate.giftSuggestions)
|
||||||
|
#expect(!gate.conversationTopics)
|
||||||
|
#expect(!gate.unlimitedAIQueries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Pro-only
|
||||||
|
|
||||||
|
@Test("Pro-User: alle Pro-Features aktiv")
|
||||||
|
func proUserHasAllProFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: false)
|
||||||
|
#expect(gate.unlimitedContacts)
|
||||||
|
#expect(gate.premiumThemes)
|
||||||
|
#expect(gate.shareExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro-User: keine Max-Features")
|
||||||
|
func proUserHasNoMaxFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: false)
|
||||||
|
#expect(!gate.aiAnalysis)
|
||||||
|
#expect(!gate.giftSuggestions)
|
||||||
|
#expect(!gate.conversationTopics)
|
||||||
|
#expect(!gate.unlimitedAIQueries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Max-User
|
||||||
|
|
||||||
|
@Test("Max-User: alle Pro-Features aktiv (Max implies Pro)")
|
||||||
|
func maxUserHasAllProFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: true)
|
||||||
|
#expect(gate.unlimitedContacts, "Max-Abonnenten müssen unbegrenzte Kontakte haben")
|
||||||
|
#expect(gate.premiumThemes, "Max-Abonnenten müssen Premium-Themes haben")
|
||||||
|
#expect(gate.shareExtension, "Max-Abonnenten müssen die Share-Extension nutzen können")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Max-User: alle Max-Features aktiv")
|
||||||
|
func maxUserHasAllMaxFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: true)
|
||||||
|
#expect(gate.aiAnalysis, "Max-Abonnenten müssen KI-Analyse nutzen können")
|
||||||
|
#expect(gate.giftSuggestions, "Max-Abonnenten müssen Geschenkideen nutzen können")
|
||||||
|
#expect(gate.conversationTopics, "Max-Abonnenten müssen Gesprächsthemen nutzen können")
|
||||||
|
#expect(gate.unlimitedAIQueries, "Max-Abonnenten müssen unbegrenzte KI-Abfragen haben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate canUseAI Tests
|
||||||
|
|
||||||
|
/// Verifiziert die canUseAI-Logik, die in AddMomentView, TodayView und LogbuchView identisch ist:
|
||||||
|
/// store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
|
||||||
|
@Suite("FeatureGate – canUseAI")
|
||||||
|
struct FeatureGateCanUseAITests {
|
||||||
|
|
||||||
|
@Test("Max + keine Gratis-Abfragen: KI verfügbar (unbegrenzt)")
|
||||||
|
func maxUserAlwaysCanUseAI() {
|
||||||
|
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Max + Gratis-Abfragen vorhanden: KI verfügbar")
|
||||||
|
func maxUserWithQueriesCanUseAI() {
|
||||||
|
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht-Max + Gratis-Abfragen vorhanden: KI verfügbar")
|
||||||
|
func nonMaxWithQueriesCanUseAI() {
|
||||||
|
#expect(FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht-Max + keine Gratis-Abfragen: KI gesperrt")
|
||||||
|
func nonMaxWithoutQueriesCannotUseAI() {
|
||||||
|
#expect(!FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate shouldConsumeFreeQuery Tests
|
||||||
|
|
||||||
|
/// Verifiziert die Verbrauchs-Logik:
|
||||||
|
/// if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
|
||||||
|
@Suite("FeatureGate – shouldConsumeFreeQuery")
|
||||||
|
struct FeatureGateFreeQueryConsumptionTests {
|
||||||
|
|
||||||
|
@Test("Max-Abonnent: Gratis-Abfrage wird NICHT verbraucht")
|
||||||
|
func maxUserDoesNotConsumeFreeQuery() {
|
||||||
|
#expect(!FeatureGate.shouldConsumeFreeQuery(isMax: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht-Max-Abonnent: Gratis-Abfrage wird verbraucht")
|
||||||
|
func nonMaxUserConsumesQuery() {
|
||||||
|
#expect(FeatureGate.shouldConsumeFreeQuery(isMax: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Paywall-Targeting Tests
|
// MARK: - Paywall-Targeting Tests
|
||||||
|
|
||||||
/// Dokumentiert die Logik aus SettingsView:
|
/// Dokumentiert die Logik aus SettingsView:
|
||||||
|
|||||||
Reference in New Issue
Block a user