350 lines
12 KiB
Dart
350 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/durable_verification_queue.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),
|
|
verificationQueueProvider.overrideWithValue(InMemoryVerificationQueue()),
|
|
],
|
|
);
|
|
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);
|
|
},
|
|
);
|
|
});
|
|
}
|