import 'dart:convert'; import 'package:flutter_test/flutter_test.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/product_catalog.dart'; import 'package:motorcycle_dash/services/billing/purchase_verification_service.dart'; import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; void main() { group('PurchaseVerificationService', () { test('missing or empty purchase token fails terminally', () async { final service = PurchaseVerificationService(); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: '', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isFalse); expect(result.failureType, VerificationFailureType.terminal); expect(result.errorCode, 'MISSING_TOKEN'); }); test('unrecognized product ID fails terminally', () async { final service = PurchaseVerificationService(); const purchase = StorePurchase( purchaseId: 'p1', productId: 'hacked_tier', status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_123', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isFalse); expect(result.failureType, VerificationFailureType.terminal); expect(result.errorCode, 'INVALID_PRODUCT'); }); test( 'successful monthly subscription response parses to active Pro', () async { final mockClient = MockClient((request) async { expect(request.url.path, '/billing/verify-play-purchase'); final body = jsonDecode(request.body) as Map; expect(body['productId'], ProductCatalog.proMonthly); expect(body['purchaseToken'], 'token_valid_123'); return http.Response( jsonEncode({ 'schemaVersion': 1, 'productId': ProductCatalog.proMonthly, 'plan': 'proMonthly', 'status': 'proMonthlyActive', 'verificationStatus': 'verified', 'verifiedAt': '2026-09-03T18:00:00.000Z', 'expiresAt': '2026-10-03T18:00:00.000Z', 'tokenHash': 'hash_123', }), 200, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_valid_123', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isTrue); expect(result.entitlementState, isNotNull); final state = result.entitlementState!; expect(state.plan, MotorSignalPlan.proMonthly); expect(state.status, MotorSignalEntitlementStatus.proMonthlyActive); expect( state.verificationStatus, EntitlementVerificationStatus.verified, ); expect(state.isPro, isTrue); expect(state.purchaseTokenHash, 'hash_123'); expect(state.expiresAt, DateTime.parse('2026-10-03T18:00:00.000Z')); }, ); test( 'subscription response with pendingRetry acknowledgementState preserves state with isPendingAckRetry', () async { 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': '2026-10-03T18:00:00.000Z', 'tokenHash': 'hash_123', }), 200, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_valid_123', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isTrue); expect(result.acknowledgementState, 'pendingRetry'); expect(result.isPendingAckRetry, isTrue); expect(result.isAcknowledged, isFalse); expect(result.entitlementState!.isPro, isTrue); }, ); test( 'paused subscription response parses to proMonthlyPaused and denies Pro', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({ 'schemaVersion': 1, 'productId': ProductCatalog.proMonthly, 'plan': 'proMonthly', 'status': 'proMonthlyPaused', 'verificationStatus': 'verified', 'acknowledgementState': 'notApplicable', 'verifiedAt': '2026-09-03T18:00:00.000Z', 'expiresAt': '2026-10-03T18:00:00.000Z', 'tokenHash': 'hash_paused', }), 200, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_paused', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isTrue); expect( result.entitlementState!.status, MotorSignalEntitlementStatus.proMonthlyPaused, ); expect(result.entitlementState!.isPro, isFalse); }, ); test( 'successful lifetime purchase parses to lifetime Pro without expiration', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({ 'schemaVersion': 1, 'productId': ProductCatalog.proLifetime, 'plan': 'proLifetime', 'status': 'proLifetime', 'verificationStatus': 'verified', 'verifiedAt': '2026-09-03T18:00:00.000Z', 'expiresAt': null, 'tokenHash': 'hash_lifetime', }), 200, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p2', productId: ProductCatalog.proLifetime, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_lifetime', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isTrue); expect(result.entitlementState!.plan, MotorSignalPlan.proLifetime); expect( result.entitlementState!.status, MotorSignalEntitlementStatus.proLifetime, ); expect(result.entitlementState!.expiresAt, isNull); expect(result.entitlementState!.isPro, isTrue); }, ); test( 'revoked lifetime purchase parses safely to non-pro revoked status', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({ 'schemaVersion': 1, 'productId': ProductCatalog.proLifetime, 'plan': 'proLifetime', 'status': 'unknown', 'verificationStatus': 'revoked', 'verifiedAt': '2026-09-03T18:00:00.000Z', 'expiresAt': null, 'tokenHash': 'hash_revoked', }), 200, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p2', productId: ProductCatalog.proLifetime, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_revoked', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isTrue); expect(result.entitlementState!.isPro, isFalse); expect( result.entitlementState!.verificationStatus, EntitlementVerificationStatus.revoked, ); }, ); test('client 400 rejection classifies as terminal failure', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({ 'code': 'INVALID_PURCHASE', 'error': 'Purchase token not found in Google Play', }), 404, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'invalid_token', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isFalse); expect(result.failureType, VerificationFailureType.terminal); expect(result.errorCode, 'INVALID_PURCHASE'); }); test('rate limited 429 classifies as retryable failure', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({'code': 'RATE_LIMITED', 'error': 'Too many requests'}), 429, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_123', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isFalse); expect(result.failureType, VerificationFailureType.retryable); expect(result.errorCode, 'RATE_LIMITED'); }); test('worker 502/503 classifies as retryable failure', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({ 'code': 'GOOGLE_TEMPORARILY_UNAVAILABLE', 'error': 'Google service unavailable', }), 502, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_123', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isFalse); expect(result.failureType, VerificationFailureType.retryable); expect(result.isRetryable, isTrue); }); test( 'unsupported future schema version classifies as retryable without unlocking Pro', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({ 'schemaVersion': 99, 'productId': ProductCatalog.proMonthly, 'plan': 'proMonthly', 'status': 'proMonthlyActive', 'verificationStatus': 'verified', 'verifiedAt': '2026-09-03T18:00:00.000Z', }), 200, headers: {'content-type': 'application/json'}, ); }); final service = PurchaseVerificationService( client: mockClient, workerBaseUrl: 'https://test.worker', ); const purchase = StorePurchase( purchaseId: 'p1', productId: ProductCatalog.proMonthly, status: StorePurchaseStatus.purchased, transactionDate: '2026-09-01T12:00:00Z', purchaseToken: 'token_123', ); final result = await service.verifyPurchase(purchase: purchase); expect(result.success, isFalse); expect(result.errorCode, 'UNSUPPORTED_SCHEMA'); expect(result.entitlementState, isNull); }, ); }); }