402 lines
14 KiB
Dart
402 lines
14 KiB
Dart
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);
|
|
},
|
|
);
|
|
});
|
|
}
|