MotorSignal-App/test/billing_lifecycle_test.dart

348 lines
12 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/services/billing/billing_models.dart';
import 'package:motorcycle_dash/services/billing/billing_providers.dart';
import 'package:motorcycle_dash/services/billing/fake_billing_service.dart';
import 'package:motorcycle_dash/services/billing/play_billing_service.dart';
import 'package:motorcycle_dash/services/billing/product_catalog.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_coordinator.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart';
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ProductCatalog rules', () {
test('contains expected product IDs and correct classification', () {
expect(ProductCatalog.proMonthly, 'motorsignal_pro_monthly');
expect(ProductCatalog.proLifetime, 'motorsignal_pro_lifetime');
expect(ProductCatalog.allProductIds, contains('motorsignal_pro_monthly'));
expect(
ProductCatalog.allProductIds,
contains('motorsignal_pro_lifetime'),
);
expect(ProductCatalog.isSubscription('motorsignal_pro_monthly'), isTrue);
expect(
ProductCatalog.isSubscription('motorsignal_pro_lifetime'),
isFalse,
);
expect(ProductCatalog.isLifetime('motorsignal_pro_lifetime'), isTrue);
expect(ProductCatalog.isLifetime('motorsignal_pro_monthly'), isFalse);
expect(ProductCatalog.isValidProduct('unknown_product'), isFalse);
});
});
group('BillingService & FakeBillingService lifecycle', () {
test('initializes and reports availability correctly', () async {
final fakeBilling = FakeBillingService();
expect(await fakeBilling.initialize(), isTrue);
expect(await fakeBilling.isAvailable(), isTrue);
fakeBilling.available = false;
expect(await fakeBilling.isAvailable(), isFalse);
});
test('queryProducts returns localized product catalog', () async {
final fakeBilling = FakeBillingService();
final products = await fakeBilling.queryProducts();
expect(products.length, 2);
final monthly = products.firstWhere(
(p) => p.id == ProductCatalog.proMonthly,
);
expect(monthly.isSubscription, isTrue);
expect(monthly.price, '\$4.99');
expect(monthly.rawPrice, 4.99);
final lifetime = products.firstWhere(
(p) => p.id == ProductCatalog.proLifetime,
);
expect(lifetime.isSubscription, isFalse);
expect(lifetime.price, '\$49.99');
expect(lifetime.rawPrice, 49.99);
});
test('queryProducts handles store unavailable safely', () async {
final fakeBilling = FakeBillingService()..available = false;
final products = await fakeBilling.queryProducts();
expect(products, isEmpty);
});
test('launchPurchaseFlow tracks requests and validates product', () async {
final fakeBilling = FakeBillingService();
final success = await fakeBilling.launchPurchaseFlow(
ProductCatalog.proMonthly,
);
expect(success, isTrue);
expect(fakeBilling.launchCount, 1);
expect(fakeBilling.lastPurchasedProductId, ProductCatalog.proMonthly);
final invalidSuccess = await fakeBilling.launchPurchaseFlow(
'invalid_sku',
);
expect(invalidSuccess, isFalse);
});
test('restorePurchases increments call count', () async {
final fakeBilling = FakeBillingService();
await fakeBilling.restorePurchases();
expect(fakeBilling.restoreCount, 1);
});
});
group('EntitlementCoordinator purchase lifecycle & verification boundary', () {
late FakeBillingService billing;
late InMemoryEntitlementStorage storage;
late EntitlementCoordinator coordinator;
setUp(() async {
billing = FakeBillingService();
storage = InMemoryEntitlementStorage();
coordinator = EntitlementCoordinator(
billingService: billing,
storage: storage,
);
await coordinator.initialize();
});
tearDown(() {
coordinator.dispose();
billing.dispose();
});
test('initial state defaults to cached free tier', () {
expect(coordinator.state.plan, MotorSignalPlan.free);
expect(coordinator.state.isPro, isFalse);
});
test(
'purchase observed transitions unverified state to syncPending (does NOT unlock Pro yet)',
() async {
final purchase = billing.createFakePurchase(
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
);
billing.emitPurchases([purchase]);
await Future<void>.delayed(Duration.zero);
// Critical Stage 4B-2 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED
expect(
coordinator.state.status,
MotorSignalEntitlementStatus.syncPending,
);
expect(
coordinator.state.isPro,
isFalse,
reason: 'Must not grant Pro before server verification',
);
expect(coordinator.unverifiedPurchases.length, 1);
expect(
coordinator.unverifiedPurchases.first.purchaseId,
purchase.purchaseId,
);
},
);
test(
'pending store purchase transitions status to syncPending without granting access',
() async {
final pendingPurchase = billing.createFakePurchase(
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.pending,
);
billing.emitPurchases([pendingPurchase]);
await Future<void>.delayed(Duration.zero);
expect(
coordinator.state.status,
MotorSignalEntitlementStatus.syncPending,
);
expect(coordinator.state.isPro, isFalse);
},
);
test(
'canceled or error purchase events do not erase existing verified entitlement',
() async {
// Setup an active verified Pro state in storage
final verifiedState = EntitlementState.proLifetime();
await coordinator.applyVerifiedEntitlement(verifiedState);
expect(coordinator.state.isPro, isTrue);
// Now emit an error or canceled event
final errorPurchase = billing.createFakePurchase(
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.error,
);
billing.emitPurchases([errorPurchase]);
await Future<void>.delayed(Duration.zero);
// State remains verified Pro
expect(coordinator.state.isPro, isTrue);
expect(coordinator.state.plan, MotorSignalPlan.proLifetime);
},
);
test(
'restored purchase is staged for verification without automatic unlock',
() async {
final restoredPurchase = billing.createFakePurchase(
productId: ProductCatalog.proLifetime,
status: StorePurchaseStatus.restored,
);
billing.emitPurchases([restoredPurchase]);
await Future<void>.delayed(Duration.zero);
expect(coordinator.unverifiedPurchases.length, 1);
expect(
coordinator.state.status,
MotorSignalEntitlementStatus.syncPending,
);
expect(coordinator.state.isPro, isFalse);
},
);
test('unknown product IDs are ignored by coordinator', () async {
final unknownPurchase = billing.createFakePurchase(
productId: 'hacked_super_pro',
status: StorePurchaseStatus.purchased,
);
billing.emitPurchases([unknownPurchase]);
await Future<void>.delayed(Duration.zero);
expect(coordinator.unverifiedPurchases, isEmpty);
expect(coordinator.state.status, MotorSignalEntitlementStatus.free);
});
test(
'applying verified entitlement completes purchase and updates durable storage',
() async {
final purchase = billing.createFakePurchase(
productId: ProductCatalog.proLifetime,
status: StorePurchaseStatus.purchased,
token: 'valid_token_123',
);
billing.emitPurchases([purchase]);
await Future<void>.delayed(Duration.zero);
expect(coordinator.unverifiedPurchases.length, 1);
// Simulate verification callback from Stage 4B-3
final verifiedState = EntitlementState.proLifetime(
productId: ProductCatalog.proLifetime,
purchaseTokenHash: purchase.purchaseTokenHash,
);
await coordinator.applyVerifiedEntitlement(
verifiedState,
completedPurchase: purchase,
);
expect(coordinator.state.isPro, isTrue);
expect(coordinator.unverifiedPurchases, isEmpty);
expect(billing.isPurchaseCompleted('valid_token_123'), isTrue);
// Verify persisted to storage
final cached = await storage.read();
expect(cached.isPro, isTrue);
expect(cached.plan, MotorSignalPlan.proLifetime);
},
);
});
group('Riverpod integration & Provider boundaries', () {
test(
'featureAccessProvider reacts dynamically to verified coordinator changes',
() async {
final fakeBilling = FakeBillingService();
final storage = InMemoryEntitlementStorage();
final container = ProviderContainer(
overrides: [
billingServiceProvider.overrideWithValue(fakeBilling),
entitlementStorageProvider.overrideWithValue(storage),
],
);
addTearDown(container.dispose);
final coordinator = container.read(entitlementCoordinatorProvider);
await coordinator.initialize();
// Starts Free
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isFalse,
);
// Emit store purchase (unverified -> still false)
final purchase = fakeBilling.createFakePurchase(
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
);
fakeBilling.emitPurchases([purchase]);
await Future<void>.delayed(Duration.zero);
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isFalse,
);
// Apply verification -> unlocks csvExport
await coordinator.applyVerifiedEntitlement(
EntitlementState.proMonthlyActive(),
);
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isTrue,
);
},
);
test(
'production billingServiceProvider defaults to PlayBillingService',
() {
final container = ProviderContainer();
addTearDown(container.dispose);
final service = container.read(billingServiceProvider);
expect(service, isA<PlayBillingService>());
expect(service, isNot(isA<FakeBillingService>()));
},
);
});
group('Account Independence & Safety', () {
test(
'billing service has no dependency on Firebase Auth or cloud accounts',
() {
final billing = FakeBillingService();
expect(billing, isA<FakeBillingService>());
// Zero AuthUser or Firebase imports required
},
);
test(
'restoring purchases operates without any account prerequisites',
() async {
final billing = FakeBillingService();
await billing.restorePurchases();
expect(billing.restoreCount, 1);
},
);
});
}