MotorSignal-App/test/billing_lifecycle_test.dart

530 lines
18 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.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/billing/purchase_verification_service.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);
},
);
test(
'verifyStorePurchase dequeues purchase when result is fully acknowledged',
() async {
final futureExpiry = DateTime.now().toUtc().add(const Duration(days: 30));
final queue = InMemoryVerificationQueue();
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'schemaVersion': 1,
'productId': ProductCatalog.proMonthly,
'plan': 'proMonthly',
'status': 'proMonthlyActive',
'verificationStatus': 'verified',
'acknowledgementState': 'acknowledged',
'verifiedAt': '2026-09-03T18:00:00.000Z',
'expiresAt': futureExpiry.toIso8601String(),
'tokenHash': 'hash_ack',
}),
200,
headers: {'content-type': 'application/json'},
);
});
final verifier = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
final coordWithVerifier = EntitlementCoordinator(
billingService: billing,
storage: storage,
verificationService: verifier,
verificationQueue: queue,
);
addTearDown(coordWithVerifier.dispose);
await coordWithVerifier.initialize();
const purchase = StorePurchase(
purchaseId: 'order_ack_1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_ack_123',
);
await queue.enqueue(purchase);
expect(await queue.loadQueue(), hasLength(1));
await coordWithVerifier.verifyStorePurchase(purchase);
expect(coordWithVerifier.state.isPro, isTrue);
expect(await queue.loadQueue(), isEmpty);
},
);
test(
'verifyStorePurchase grants Pro but retains queue item with backoff on pendingRetry ack',
() async {
final futureExpiry = DateTime.now().toUtc().add(const Duration(days: 30));
final queue = InMemoryVerificationQueue();
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'schemaVersion': 1,
'productId': ProductCatalog.proMonthly,
'plan': 'proMonthly',
'status': 'proMonthlyActive',
'verificationStatus': 'verified',
'acknowledgementState': 'pendingRetry',
'verifiedAt': '2026-09-03T18:00:00.000Z',
'expiresAt': futureExpiry.toIso8601String(),
'tokenHash': 'hash_pending_ack',
}),
200,
headers: {'content-type': 'application/json'},
);
});
final verifier = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
final coordWithVerifier = EntitlementCoordinator(
billingService: billing,
storage: storage,
verificationService: verifier,
verificationQueue: queue,
);
addTearDown(coordWithVerifier.dispose);
await coordWithVerifier.initialize();
const purchase = StorePurchase(
purchaseId: 'order_pending_1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_pending_123',
);
await queue.enqueue(purchase);
expect(await queue.loadQueue(), hasLength(1));
await coordWithVerifier.verifyStorePurchase(purchase);
// User receives verified Pro access immediately
expect(coordWithVerifier.state.isPro, isTrue);
// But queue item is NOT removed; recorded with retry backoff to finalize server ack
final queueItems = await queue.loadQueue();
expect(queueItems, hasLength(1));
expect(queueItems.first.attemptCount, 1);
expect(
queueItems.first.nextRetryAt.isAfter(
DateTime.now().add(const Duration(minutes: 4)),
),
isTrue,
);
},
);
test(
'verifyStorePurchase with past expiresAt sets status to expired and does not grant Pro',
() async {
final pastExpiry = DateTime.now().toUtc().subtract(const Duration(days: 1));
final queue = InMemoryVerificationQueue();
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'schemaVersion': 1,
'productId': ProductCatalog.proMonthly,
'plan': 'proMonthly',
'status': 'proMonthlyActive',
'verificationStatus': 'verified',
'acknowledgementState': 'acknowledged',
'verifiedAt': '2026-09-03T18:00:00.000Z',
'expiresAt': pastExpiry.toIso8601String(),
'tokenHash': 'hash_expired',
}),
200,
headers: {'content-type': 'application/json'},
);
});
final verifier = PurchaseVerificationService(
client: mockClient,
workerBaseUrl: 'https://test.worker',
);
final coordWithVerifier = EntitlementCoordinator(
billingService: billing,
storage: storage,
verificationService: verifier,
verificationQueue: queue,
);
addTearDown(coordWithVerifier.dispose);
await coordWithVerifier.initialize();
const purchase = StorePurchase(
purchaseId: 'order_expired_1',
productId: ProductCatalog.proMonthly,
status: StorePurchaseStatus.purchased,
transactionDate: '2026-09-01T12:00:00Z',
purchaseToken: 'token_expired_123',
);
await queue.enqueue(purchase);
await coordWithVerifier.verifyStorePurchase(purchase);
expect(coordWithVerifier.state.isPro, isFalse);
expect(
coordWithVerifier.state.status,
MotorSignalEntitlementStatus.proMonthlyExpired,
);
},
);
});
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);
},
);
});
}