import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/servicios/servicio_compras.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; @override Stream get eventos => _eventos.stream; @override Future comprar() async { comprasIntentadas++; } @override Future restaurar() async { restaurosIntentados++; } void emitir(EventoCompra evento) => _eventos.add(evento); Future dispose() => _eventos.close(); } 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( '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('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); }, ); }); }