350 lines
12 KiB
Dart
350 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
|
|
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
|
|
import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart';
|
|
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('EntitlementState domain rules', () {
|
|
test('default constructor creates free tier', () {
|
|
const state = EntitlementState();
|
|
expect(state.plan, MotorSignalPlan.free);
|
|
expect(state.status, MotorSignalEntitlementStatus.free);
|
|
expect(state.isPro, isFalse);
|
|
expect(state.canCloudBackup, isTrue);
|
|
expect(state.canUnlimitedCloudHistory, isFalse);
|
|
expect(state.canAdvancedTelemetry, isFalse);
|
|
expect(state.canExportCsv, isFalse);
|
|
});
|
|
|
|
test('EntitlementState.free factory normalizes to valid free tier', () {
|
|
const state = EntitlementState.free();
|
|
expect(state.plan, MotorSignalPlan.free);
|
|
expect(state.status, MotorSignalEntitlementStatus.free);
|
|
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
|
|
expect(state.isPro, isFalse);
|
|
expect(state.productId, isNull);
|
|
expect(state.expiresAt, isNull);
|
|
});
|
|
|
|
test('Pro Monthly active exposes complete premium capability surface', () {
|
|
final now = DateTime.now();
|
|
final expiry = now.add(const Duration(days: 30));
|
|
final state = EntitlementState.proMonthlyActive(
|
|
productId: 'motorsignal_pro_monthly',
|
|
purchaseTokenHash: 'abc123hash',
|
|
expiresAt: expiry,
|
|
lastVerifiedAt: now,
|
|
);
|
|
|
|
expect(state.isPro, isTrue);
|
|
expect(state.plan, MotorSignalPlan.proMonthly);
|
|
expect(state.status, MotorSignalEntitlementStatus.proMonthlyActive);
|
|
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
|
|
expect(state.source, PurchaseSource.googlePlay);
|
|
expect(state.productId, 'motorsignal_pro_monthly');
|
|
expect(state.purchaseTokenHash, 'abc123hash');
|
|
expect(state.expiresAt, expiry);
|
|
|
|
// Verify all Pro capabilities
|
|
expect(state.canCloudBackup, isTrue);
|
|
expect(state.canUnlimitedCloudHistory, isTrue);
|
|
expect(state.canMultiDeviceRestore, isTrue);
|
|
expect(state.canAdvancedTelemetry, isTrue);
|
|
expect(state.canAdvancedAcceleration, isTrue);
|
|
expect(state.canAdvancedBraking, isTrue);
|
|
expect(state.canAdvancedLean, isTrue);
|
|
expect(state.canAdvancedAnalytics, isTrue);
|
|
expect(state.canUnlimitedHistory, isTrue);
|
|
expect(state.canExportCsv, isTrue);
|
|
expect(state.canExportPdf, isTrue);
|
|
expect(state.canUseMultipleMotorcycles, isTrue);
|
|
expect(state.canPlanTrips, isTrue);
|
|
expect(state.canCreateGroupRides, isTrue);
|
|
expect(state.canUseAdvancedGroupRideFeatures, isTrue);
|
|
});
|
|
|
|
test('Pro Monthly Grace provides effective premium access', () {
|
|
final state = EntitlementState(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.proMonthlyGrace,
|
|
verificationStatus: EntitlementVerificationStatus.offlineGrace,
|
|
source: PurchaseSource.offlineCache,
|
|
productId: 'motorsignal_pro_monthly',
|
|
);
|
|
|
|
expect(state.isPro, isTrue);
|
|
expect(state.canAdvancedTelemetry, isTrue);
|
|
expect(state.canExportCsv, isTrue);
|
|
});
|
|
|
|
test('Pro Monthly Expired denies premium access', () {
|
|
final state = EntitlementState(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.proMonthlyExpired,
|
|
verificationStatus: EntitlementVerificationStatus.expired,
|
|
);
|
|
|
|
expect(state.isPro, isFalse);
|
|
expect(state.canCloudBackup, isTrue);
|
|
expect(state.canAdvancedTelemetry, isFalse);
|
|
expect(state.canExportCsv, isFalse);
|
|
expect(state.canUnlimitedHistory, isFalse);
|
|
});
|
|
|
|
test('Pro Monthly Account Hold denies premium access', () {
|
|
final state = EntitlementState(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.proMonthlyAccountHold,
|
|
);
|
|
|
|
expect(state.isPro, isFalse);
|
|
});
|
|
|
|
test('Pro Lifetime exposes full capabilities without expiration', () {
|
|
final state = EntitlementState.proLifetime(
|
|
productId: 'motorsignal_pro_lifetime',
|
|
purchaseTokenHash: 'lifetime_hash',
|
|
);
|
|
|
|
expect(state.isPro, isTrue);
|
|
expect(state.plan, MotorSignalPlan.proLifetime);
|
|
expect(state.status, MotorSignalEntitlementStatus.proLifetime);
|
|
expect(state.expiresAt, isNull);
|
|
expect(state.canUnlimitedCloudHistory, isTrue);
|
|
expect(state.canAdvancedLean, isTrue);
|
|
expect(state.canPlanTrips, isTrue);
|
|
});
|
|
|
|
test('Unknown or sync pending statuses do not grant Pro access', () {
|
|
const unknownState = EntitlementState(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.unknown,
|
|
);
|
|
expect(unknownState.isPro, isFalse);
|
|
|
|
const pendingState = EntitlementState(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.syncPending,
|
|
);
|
|
expect(pendingState.isPro, isFalse);
|
|
});
|
|
});
|
|
|
|
group('EntitlementState normalization & invalid state prevention', () {
|
|
test('normalizes contradictory free plan with pro status to pure free', () {
|
|
final normalized = EntitlementState.normalized(
|
|
plan: MotorSignalPlan.free,
|
|
status: MotorSignalEntitlementStatus.proLifetime,
|
|
productId: 'some_product',
|
|
expiresAt: DateTime.now().add(const Duration(days: 10)),
|
|
);
|
|
|
|
expect(normalized.plan, MotorSignalPlan.free);
|
|
expect(normalized.status, MotorSignalEntitlementStatus.free);
|
|
expect(normalized.isPro, isFalse);
|
|
expect(normalized.productId, isNull);
|
|
expect(normalized.expiresAt, isNull);
|
|
});
|
|
|
|
test('normalizes revoked verification status to non-pro status', () {
|
|
final normalized = EntitlementState.normalized(
|
|
plan: MotorSignalPlan.proLifetime,
|
|
status: MotorSignalEntitlementStatus.proLifetime,
|
|
verificationStatus: EntitlementVerificationStatus.revoked,
|
|
);
|
|
|
|
expect(
|
|
normalized.verificationStatus,
|
|
EntitlementVerificationStatus.revoked,
|
|
);
|
|
expect(normalized.isPro, isFalse);
|
|
});
|
|
|
|
test('normalizes expired subscription with past expiresAt date', () {
|
|
final pastDate = DateTime.now().subtract(const Duration(days: 2));
|
|
final normalized = EntitlementState.normalized(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.proMonthlyActive,
|
|
expiresAt: pastDate,
|
|
);
|
|
|
|
expect(normalized.status, MotorSignalEntitlementStatus.proMonthlyExpired);
|
|
expect(
|
|
normalized.verificationStatus,
|
|
EntitlementVerificationStatus.expired,
|
|
);
|
|
expect(normalized.isPro, isFalse);
|
|
});
|
|
|
|
test('normalizes Lifetime plan ensuring null expiration', () {
|
|
final normalized = EntitlementState.normalized(
|
|
plan: MotorSignalPlan.proLifetime,
|
|
status: MotorSignalEntitlementStatus.proMonthlyActive,
|
|
expiresAt: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
|
|
expect(normalized.plan, MotorSignalPlan.proLifetime);
|
|
expect(normalized.status, MotorSignalEntitlementStatus.proLifetime);
|
|
expect(normalized.expiresAt, isNull);
|
|
expect(normalized.isPro, isTrue);
|
|
});
|
|
});
|
|
|
|
group('FeatureAccessService exhaustive mapping', () {
|
|
test('every MotorSignalFeature enum entry maps to explicit capability', () {
|
|
const freeService = FeatureAccessService(EntitlementState.free());
|
|
final proService = FeatureAccessService(EntitlementState.proLifetime());
|
|
|
|
for (final feature in MotorSignalFeature.values) {
|
|
// Must execute cleanly without exception
|
|
final freeResult = freeService.canAccess(feature);
|
|
final proResult = proService.canAccess(feature);
|
|
|
|
if (feature == MotorSignalFeature.cloudBackup) {
|
|
expect(
|
|
freeResult,
|
|
isTrue,
|
|
reason: 'Cloud backup included in free tier',
|
|
);
|
|
expect(proResult, isTrue);
|
|
} else {
|
|
expect(
|
|
freeResult,
|
|
isFalse,
|
|
reason: '$feature should be denied on Free tier',
|
|
);
|
|
expect(
|
|
proResult,
|
|
isTrue,
|
|
reason: '$feature should be granted on Pro tier',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
group('Entitlement serialization & storage contract', () {
|
|
test('toJson and fromJson perform lossless round trip', () {
|
|
final original = EntitlementState(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.proMonthlyActive,
|
|
verificationStatus: EntitlementVerificationStatus.verified,
|
|
source: PurchaseSource.googlePlay,
|
|
productId: 'motorsignal_pro_monthly',
|
|
purchaseTokenHash: 'token_sha256_hash',
|
|
expiresAt: DateTime.utc(2030, 1, 1),
|
|
lastVerifiedAt: DateTime.utc(2026, 9, 1),
|
|
);
|
|
|
|
final json = original.toJson();
|
|
final restored = EntitlementState.fromJson(json);
|
|
|
|
expect(restored.plan, original.plan);
|
|
expect(restored.status, original.status);
|
|
expect(restored.verificationStatus, original.verificationStatus);
|
|
expect(restored.source, original.source);
|
|
expect(restored.productId, original.productId);
|
|
expect(restored.purchaseTokenHash, original.purchaseTokenHash);
|
|
expect(restored.expiresAt, original.expiresAt);
|
|
expect(restored.lastVerifiedAt, original.lastVerifiedAt);
|
|
expect(restored.isPro, isTrue);
|
|
});
|
|
|
|
test('fromJson throws on unsupported future version', () {
|
|
final invalidVersionJson = {'version': 99, 'plan': 'proMonthly'};
|
|
|
|
expect(
|
|
() => EntitlementState.fromJson(invalidVersionJson),
|
|
throwsA(isA<FormatException>()),
|
|
);
|
|
});
|
|
|
|
test('InMemoryEntitlementStorage behaves according to contract', () async {
|
|
final storage = InMemoryEntitlementStorage();
|
|
|
|
// Missing cache defaults to Free
|
|
final initial = await storage.read();
|
|
expect(initial.plan, MotorSignalPlan.free);
|
|
expect(initial.isPro, isFalse);
|
|
|
|
// Write Pro Lifetime
|
|
final proLifetime = EntitlementState.proLifetime(productId: 'lifetime');
|
|
await storage.write(proLifetime);
|
|
|
|
final cached = await storage.read();
|
|
expect(cached.plan, MotorSignalPlan.proLifetime);
|
|
expect(cached.isPro, isTrue);
|
|
|
|
// Clear reverts to Free
|
|
await storage.clear();
|
|
final cleared = await storage.read();
|
|
expect(cleared.plan, MotorSignalPlan.free);
|
|
expect(cleared.isPro, isFalse);
|
|
});
|
|
});
|
|
|
|
group('Riverpod Provider integration', () {
|
|
test(
|
|
'featureAccessProvider reacts to entitlementProvider updates',
|
|
() async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
entitlementStorageProvider.overrideWithValue(
|
|
InMemoryEntitlementStorage(),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
// Default is Free
|
|
expect(
|
|
container
|
|
.read(featureAccessProvider)
|
|
.canAccess(MotorSignalFeature.csvExport),
|
|
isFalse,
|
|
);
|
|
|
|
// Update to Pro Lifetime
|
|
await container
|
|
.read(entitlementCoordinatorProvider)
|
|
.applyVerifiedEntitlement(EntitlementState.proLifetime());
|
|
expect(
|
|
container
|
|
.read(featureAccessProvider)
|
|
.canAccess(MotorSignalFeature.csvExport),
|
|
isTrue,
|
|
);
|
|
|
|
// Downgrade to Expired
|
|
await container
|
|
.read(entitlementCoordinatorProvider)
|
|
.applyVerifiedEntitlement(
|
|
const EntitlementState(
|
|
plan: MotorSignalPlan.proMonthly,
|
|
status: MotorSignalEntitlementStatus.proMonthlyExpired,
|
|
),
|
|
);
|
|
expect(
|
|
container
|
|
.read(featureAccessProvider)
|
|
.canAccess(MotorSignalFeature.csvExport),
|
|
isFalse,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('Architectural boundaries & Rider safety invariant', () {
|
|
test('entitlement domain requires zero Firebase or auth dependencies', () {
|
|
// Constructs, normalizes, serializes, queries access with no Firebase context
|
|
const state = EntitlementState.free();
|
|
const service = FeatureAccessService(state);
|
|
expect(service.canAccess(MotorSignalFeature.cloudBackup), isTrue);
|
|
});
|
|
});
|
|
}
|