Verificacion silenciosa en segundo plano con queryPastPurchases, al abrir o volver a la app y al cargar Android Auto. Solo revoca tras dos respuestas validas de Play sin la compra separadas 12h; sin red o con error no toca nada. Reactiva el PRO automaticamente si Play confirma la compra.
273 lines
8.8 KiB
Dart
273 lines
8.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// Silent license re-verification policy (refund revocation): pure,
|
|
/// headless-safe, driven by an injectable clock and a fake ownership query —
|
|
/// zero plugin channels.
|
|
|
|
/// Scripted ownership query: returns [resultados] in order and counts calls.
|
|
class _ConsultaFalsa {
|
|
_ConsultaFalsa(this.resultados);
|
|
|
|
final List<ResultadoVerificacionLicencia> resultados;
|
|
int llamadas = 0;
|
|
|
|
Future<ResultadoVerificacionLicencia> call() async {
|
|
final resultado = resultados[llamadas.clamp(0, resultados.length - 1)];
|
|
llamadas++;
|
|
return resultado;
|
|
}
|
|
}
|
|
|
|
/// Mutable clock so each test can move time forward between checks.
|
|
class _Reloj {
|
|
_Reloj(this.ahora);
|
|
|
|
DateTime ahora;
|
|
|
|
DateTime call() => ahora;
|
|
|
|
void avanzar(Duration d) => ahora = ahora.add(d);
|
|
}
|
|
|
|
void main() {
|
|
late _Reloj reloj;
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
reloj = _Reloj(DateTime(2026, 9, 18, 10));
|
|
});
|
|
|
|
Future<SharedPreferences> prefsCon({required bool premium}) async {
|
|
SharedPreferences.setMockInitialValues({claveCompraPremium: premium});
|
|
return SharedPreferences.getInstance();
|
|
}
|
|
|
|
Future<CambioLicencia> verificar(
|
|
SharedPreferences prefs,
|
|
Future<ResultadoVerificacionLicencia> Function() consultar,
|
|
) => verificarLicencia(consultar: consultar, prefs: prefs, reloj: reloj.call);
|
|
|
|
group('verificarLicencia', () {
|
|
test('desconocido (offline / sin billing) conserva premium y no toca el '
|
|
'contador de ausencias', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
final consulta = _ConsultaFalsa([
|
|
ResultadoVerificacionLicencia.noPoseida,
|
|
]);
|
|
await verificar(prefs, consulta.call);
|
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
|
|
|
reloj.avanzar(const Duration(days: 2));
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.desconocido,
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.sinCambios);
|
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
|
});
|
|
|
|
test('una excepcion en la consulta no cambia nada (fail-open)', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => throw Exception('BillingClient desconectado'),
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.sinCambios);
|
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
|
});
|
|
|
|
test('poseida conserva premium y reinicia el contador', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
|
|
|
reloj.avanzar(const Duration(days: 1));
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.poseida,
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.sinCambios);
|
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
|
});
|
|
|
|
test('poseida con la flag en false desbloquea en silencio '
|
|
'(reinstalacion)', () async {
|
|
final prefs = await prefsCon(premium: false);
|
|
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.poseida,
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.desbloqueada);
|
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
|
});
|
|
|
|
test('una sola ausencia NO revoca', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.sinCambios);
|
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
|
});
|
|
|
|
test('dos ausencias separadas por el umbral revocan', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
|
|
reloj.avanzar(intervaloVerificacionLicencia);
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.revocada);
|
|
expect(prefs.getBool(claveCompraPremium), isFalse);
|
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
|
});
|
|
|
|
test('dos ausencias demasiado juntas NO revocan', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
// Simulates the throttle having been bypassed (e.g. prefs cleared by
|
|
// a second engine): only the spacing guard stands between a transient
|
|
// empty Play cache and a false revocation.
|
|
await prefs.remove(claveUltimaVerificacionLicencia);
|
|
await prefs.remove(claveUltimoIntentoLicencia);
|
|
|
|
reloj.avanzar(separacionMinimaAusencias - const Duration(minutes: 1));
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.sinCambios);
|
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
|
});
|
|
|
|
test('ausencia seguida de poseida reinicia: la siguiente ausencia '
|
|
'vuelve a contar desde cero', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
final consulta = _ConsultaFalsa([
|
|
ResultadoVerificacionLicencia.noPoseida,
|
|
ResultadoVerificacionLicencia.poseida,
|
|
ResultadoVerificacionLicencia.noPoseida,
|
|
]);
|
|
|
|
await verificar(prefs, consulta.call);
|
|
reloj.avanzar(const Duration(days: 1));
|
|
await verificar(prefs, consulta.call);
|
|
reloj.avanzar(const Duration(days: 1));
|
|
final cambio = await verificar(prefs, consulta.call);
|
|
|
|
expect(consulta.llamadas, 3);
|
|
expect(cambio, CambioLicencia.sinCambios);
|
|
expect(prefs.getBool(claveCompraPremium), isTrue);
|
|
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
|
});
|
|
|
|
test('noPoseida con la flag ya en false no hace nada', () async {
|
|
final prefs = await prefsCon(premium: false);
|
|
|
|
final cambio = await verificar(
|
|
prefs,
|
|
() async => ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
|
|
expect(cambio, CambioLicencia.sinCambios);
|
|
expect(prefs.getBool(claveCompraPremium), isFalse);
|
|
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
|
});
|
|
|
|
test('throttle: no vuelve a consultar dentro de las 24h tras una '
|
|
'verificacion con respuesta', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
final consulta = _ConsultaFalsa([ResultadoVerificacionLicencia.poseida]);
|
|
|
|
await verificar(prefs, consulta.call);
|
|
reloj.avanzar(intervaloVerificacionLicencia - const Duration(minutes: 1));
|
|
await verificar(prefs, consulta.call);
|
|
expect(consulta.llamadas, 1);
|
|
|
|
reloj.avanzar(const Duration(minutes: 1));
|
|
await verificar(prefs, consulta.call);
|
|
expect(consulta.llamadas, 2);
|
|
});
|
|
|
|
test('throttle: tras un desconocido reintenta pasada la ventana corta, '
|
|
'no antes', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
final consulta = _ConsultaFalsa([
|
|
ResultadoVerificacionLicencia.desconocido,
|
|
]);
|
|
|
|
await verificar(prefs, consulta.call);
|
|
reloj.avanzar(intervaloReintentoLicencia - const Duration(minutes: 1));
|
|
await verificar(prefs, consulta.call);
|
|
expect(consulta.llamadas, 1);
|
|
|
|
reloj.avanzar(const Duration(minutes: 1));
|
|
await verificar(prefs, consulta.call);
|
|
expect(consulta.llamadas, 2);
|
|
});
|
|
|
|
test(
|
|
'un reloj que retrocede no bloquea la verificacion para siempre',
|
|
() async {
|
|
final prefs = await prefsCon(premium: true);
|
|
final consulta = _ConsultaFalsa([
|
|
ResultadoVerificacionLicencia.poseida,
|
|
]);
|
|
|
|
await verificar(prefs, consulta.call);
|
|
reloj.avanzar(const Duration(days: -3));
|
|
await verificar(prefs, consulta.call);
|
|
|
|
expect(consulta.llamadas, 2);
|
|
},
|
|
);
|
|
|
|
test('solo UNA verificacion en vuelo: llamadas concurrentes comparten la '
|
|
'misma consulta', () async {
|
|
final prefs = await prefsCon(premium: true);
|
|
final respuesta = Completer<ResultadoVerificacionLicencia>();
|
|
var llamadas = 0;
|
|
Future<ResultadoVerificacionLicencia> consultar() {
|
|
llamadas++;
|
|
return respuesta.future;
|
|
}
|
|
|
|
final a = verificar(prefs, consultar);
|
|
final b = verificar(prefs, consultar);
|
|
respuesta.complete(ResultadoVerificacionLicencia.poseida);
|
|
await Future.wait([a, b]);
|
|
|
|
expect(llamadas, 1);
|
|
});
|
|
});
|
|
}
|