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:
2026-04-22 17:52:04 +02:00
parent 1ecc44a625
commit c4202cbf2f
2 changed files with 208 additions and 2 deletions
+49 -2
View File
@@ -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)
} }
+159
View File
@@ -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: