diff --git a/nahbar/nahbar/StoreManager.swift b/nahbar/nahbar/StoreManager.swift index 374619d..2585366 100644 --- a/nahbar/nahbar/StoreManager.swift +++ b/nahbar/nahbar/StoreManager.swift @@ -2,6 +2,49 @@ import StoreKit import SwiftUI 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 enum SubscriptionTier: CaseIterable { @@ -40,6 +83,9 @@ class StoreManager: ObservableObject { /// Rückwärtskompatibilität für bestehende Aufrufstellen var product: Product? { proProduct } + /// Aktueller Feature-Zustand als testbarer Value-Type. + var features: FeatureGate { FeatureGate(isPro: isPro, isMax: isMax) } + private var transactionListenerTask: Task? = nil private init() { @@ -119,8 +165,9 @@ class StoreManager: ObservableObject { } } } - isMax = foundMax - isPro = foundPro || foundMax // Max schließt alle Pro-Features ein + let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax) + isMax = gate.isMax + isPro = gate.isPro AppGroup.saveProStatus(isPro) } diff --git a/nahbar/nahbarTests/StoreTests.swift b/nahbar/nahbarTests/StoreTests.swift index 93380d3..ec32a5e 100644 --- a/nahbar/nahbarTests/StoreTests.swift +++ b/nahbar/nahbarTests/StoreTests.swift @@ -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 /// Dokumentiert die Logik aus SettingsView: