MotorSignal-App/test/entitlement_domain_test.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);
});
});
}