MotorSignal-App/test/purchase_verification_servi...

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);
},
);
});
}