530 lines
18 KiB
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);
|
|
},
|
|
);
|
|
});
|
|
}
|