import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/servicios/servicio_audio.dart' show registrarInvalidacionArbolAuto; import 'package:pluriwave/servicios/servicio_compras.dart'; import 'package:pluriwave/servicios/verificacion_licencia.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`, /// lets each test drive [emitir] to simulate the purchase stream. class _PuertoComprasFalso implements PuertoCompras { final _eventos = StreamController.broadcast(); int comprasIntentadas = 0; int restaurosIntentados = 0; int consultasPropiedad = 0; /// What the silent ownership query answers. Defaults to [desconocido] so /// every pre-existing test keeps its old behavior (fail-open: no change). ResultadoVerificacionLicencia propiedad = ResultadoVerificacionLicencia.desconocido; @override Stream get eventos => _eventos.stream; @override Future comprar() async { comprasIntentadas++; } @override Future restaurar() async { restaurosIntentados++; } @override Future consultarPropiedad() async { consultasPropiedad++; return propiedad; } void emitir(EventoCompra evento) => _eventos.add(evento); Future dispose() => _eventos.close(); } /// Mutable clock for the throttle/spacing rules of the license check. class _Reloj { DateTime ahora = DateTime(2026, 9, 18, 10); DateTime call() => ahora; } /// Lets every microtask/async continuation of the fire-and-forget license /// check settle (mock prefs + fake port complete immediately). Future _asentar() async { for (var i = 0; i < 10; i++) { await Future.delayed(Duration.zero); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { SharedPreferences.setMockInitialValues({}); }); group('EstadoEntitlement', () { test('por defecto es free (sin flag persistida)', () async { final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); expect(estado.esPremium, isFalse); }); test('carga premium desde una flag persistida previamente', () async { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); expect(estado.esPremium, isTrue); }); test('comprar() con éxito desbloquea premium y persiste', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final prefs = await SharedPreferences.getInstance(); final estado = EstadoEntitlement(prefs: prefs, compras: compras); addTearDown(estado.dispose); await Future.delayed(Duration.zero); var notificaciones = 0; estado.addListener(() => notificaciones++); unawaited(estado.comprar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.comprada)); await Future.delayed(Duration.zero); expect(estado.esPremium, isTrue); expect(estado.compraEnCurso, isFalse); expect(compras.comprasIntentadas, 1); expect(prefs.getBool('compra_premium_v1'), isTrue); expect(notificaciones, greaterThan(0)); }); test('comprar() cancelada deja el tier free sin cargo', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.comprar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.cancelada)); await Future.delayed(Duration.zero); expect(estado.esPremium, isFalse); expect(estado.compraEnCurso, isFalse); }); test( 'comprar() ya premium es idempotente: no reintenta la compra', () async { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); await estado.comprar(); expect(compras.comprasIntentadas, 0); expect(estado.esPremium, isTrue); }, ); test('restaurar() encuentra una compra y desbloquea premium', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.restaurar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.restaurada)); await Future.delayed(Duration.zero); expect(estado.esPremium, isTrue); expect(compras.restaurosIntentados, 1); }); test('restaurar() sin compra previa mantiene free sin error', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.restaurar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); await Future.delayed(Duration.zero); expect(estado.esPremium, isFalse); expect(estado.compraEnCurso, isFalse); }); test('tras restaurar() sin compras el paywall NO queda bloqueado: se ' 'puede volver a comprar', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.restaurar()); await Future.delayed(Duration.zero); expect(estado.compraEnCurso, isTrue); compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); await Future.delayed(Duration.zero); // `compraEnCurso` deshabilita AMBOS botones de `hoja_premium.dart` // (comprar y restaurar): si se queda pegado en `true`, el usuario ya no // puede pagar nunca más. expect(estado.compraEnCurso, isFalse); unawaited(estado.comprar()); await Future.delayed(Duration.zero); expect(compras.comprasIntentadas, 1); }); test( 'un error en el flujo de compra no bloquea al pagador (fail-open)', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.comprar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.error)); await Future.delayed(Duration.zero); // Fail-open: un error NUNCA escribe `false` sobre una flag ya premium, // y tampoco inventa un `true` para un usuario free. expect(estado.esPremium, isFalse); }, ); group('resultadoUsuario (FIX 3, code review)', () { test( 'un error en el flujo de compra expone ResultadoEntitlementUsuario.error', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); expect(estado.resultadoUsuario, isNull); unawaited(estado.comprar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.error)); await Future.delayed(Duration.zero); expect(estado.resultadoUsuario, ResultadoEntitlementUsuario.error); }, ); test('restaurar() sin compra previa expone su propio resultado ' '(restauracionSinCompras), distinto de un error', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.restaurar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); await Future.delayed(Duration.zero); expect( estado.resultadoUsuario, ResultadoEntitlementUsuario.restauracionSinCompras, ); expect( estado.resultadoUsuario, isNot(ResultadoEntitlementUsuario.error), ); }); test('consumirResultadoUsuario() limpia la señal y notifica a los ' 'listeners', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.comprar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.error)); await Future.delayed(Duration.zero); expect(estado.resultadoUsuario, isNotNull); var notificaciones = 0; estado.addListener(() => notificaciones++); estado.consumirResultadoUsuario(); expect(estado.resultadoUsuario, isNull); expect(notificaciones, greaterThan(0)); // También se limpia (probado por separado) el resultado de una // restauración sin compras. unawaited(estado.restaurar()); await Future.delayed(Duration.zero); compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); await Future.delayed(Duration.zero); expect(estado.resultadoUsuario, isNotNull); estado.consumirResultadoUsuario(); expect(estado.resultadoUsuario, isNull); }); test( 'nunca expone el texto interno/de desarrollador de EventoCompra.mensaje', () async { final compras = _PuertoComprasFalso(); addTearDown(compras.dispose); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, ); addTearDown(estado.dispose); await Future.delayed(Duration.zero); unawaited(estado.comprar()); await Future.delayed(Duration.zero); compras.emitir( const EventoCompra( TipoEventoCompra.error, mensaje: 'Producto no encontrado en Play Console', ), ); await Future.delayed(Duration.zero); // resultadoUsuario es un enum tipado -- estructuralmente incapaz // de filtrar el string interno de EventoCompra.mensaje hacia la // UI. expect(estado.resultadoUsuario, ResultadoEntitlementUsuario.error); }, ); }); }); group('verificacion silenciosa de licencia (reembolsos)', () { late _PuertoComprasFalso compras; late _Reloj reloj; late int invalidaciones; setUp(() { compras = _PuertoComprasFalso(); reloj = _Reloj(); invalidaciones = 0; registrarInvalidacionArbolAuto(() => invalidaciones++); }); tearDown(() async { registrarInvalidacionArbolAuto(() {}); await compras.dispose(); }); Future crear({required bool premium}) async { SharedPreferences.setMockInitialValues({'compra_premium_v1': premium}); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), compras: compras, reloj: reloj.call, ); addTearDown(estado.dispose); await _asentar(); return estado; } test('se dispara sola al cargar, sin bloquear la carga', () async { final estado = await crear(premium: true); expect(estado.esPremium, isTrue); expect(compras.consultasPropiedad, 1); }); test('desconocido (offline) conserva premium sin notificar nada', () async { final estado = await crear(premium: true); var notificaciones = 0; estado.addListener(() => notificaciones++); reloj.ahora = reloj.ahora.add(const Duration(days: 2)); await estado.refrescarLicencia(); expect(estado.esPremium, isTrue); expect(notificaciones, 0); expect(invalidaciones, 0); }); test('una sola ausencia no revoca', () async { compras.propiedad = ResultadoVerificacionLicencia.noPoseida; final estado = await crear(premium: true); expect(estado.esPremium, isTrue); }); test('revocacion confirmada: notifica, invalida el arbol de Auto y NUNCA ' 'toca resultadoUsuario ni compraEnCurso', () async { compras.propiedad = ResultadoVerificacionLicencia.noPoseida; final estado = await crear(premium: true); final prefs = await SharedPreferences.getInstance(); // A restore the user started stays in flight, untouched. unawaited(estado.restaurar()); await _asentar(); expect(estado.compraEnCurso, isTrue); var notificaciones = 0; estado.addListener(() => notificaciones++); reloj.ahora = reloj.ahora.add(const Duration(days: 1)); await estado.refrescarLicencia(); expect(estado.esPremium, isFalse); expect(prefs.getBool('compra_premium_v1'), isFalse); expect(notificaciones, greaterThan(0)); expect(invalidaciones, 1); expect(estado.resultadoUsuario, isNull); expect(estado.compraEnCurso, isTrue); }); test('poseida con la flag en false desbloquea en silencio', () async { compras.propiedad = ResultadoVerificacionLicencia.poseida; final estado = await crear(premium: false); expect(estado.esPremium, isTrue); expect(invalidaciones, 1); expect(estado.resultadoUsuario, isNull); expect(estado.compraEnCurso, isFalse); }); test('refrescarLicencia recoge un cambio hecho por otra via (Android ' 'Auto) en prefs', () async { final estado = await crear(premium: true); final prefs = await SharedPreferences.getInstance(); await prefs.setBool('compra_premium_v1', false); await estado.refrescarLicencia(); expect(estado.esPremium, isFalse); }); test('una compra real reinicia el contador de ausencias', () async { compras.propiedad = ResultadoVerificacionLicencia.noPoseida; final estado = await crear(premium: false); final prefs = await SharedPreferences.getInstance(); await prefs.setInt(claveAusenciasLicencia, 1); compras.emitir(const EventoCompra(TipoEventoCompra.comprada)); await _asentar(); expect(estado.esPremium, isTrue); expect(prefs.getInt(claveAusenciasLicencia), isNull); }); test('sin puerto de compras no verifica nada', () async { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); final estado = EstadoEntitlement( prefs: await SharedPreferences.getInstance(), reloj: reloj.call, ); addTearDown(estado.dispose); await _asentar(); await estado.refrescarLicencia(); expect(estado.esPremium, isTrue); }); }); group('esPremiumPersistido (headless, sin BuildContext)', () { test('lee la flag persistida directamente desde prefs', () async { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); final prefs = await SharedPreferences.getInstance(); expect(await esPremiumPersistido(prefs: prefs), isTrue); }); test('por defecto (sin flag) resuelve a free', () async { final prefs = await SharedPreferences.getInstance(); expect(await esPremiumPersistido(prefs: prefs), isFalse); }); test( 'resuelve sin prefs inyectadas (SharedPreferences.getInstance)', () async { SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); expect(await esPremiumPersistido(), isTrue); }, ); }); }