fix(compras): revocar el PRO tras un reembolso sin penalizar a quien pago
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.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user