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.
340 lines
10 KiB
Dart
340 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
|
|
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
|
import 'package:pluriwave/servicios/servicio_compras.dart';
|
|
|
|
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
|
|
/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] es la parte
|
|
/// pura, y [ServicioComprasPlayBilling] se ejercita inyectando
|
|
/// [_InAppPurchaseFalso] — sin ningún platform channel real.
|
|
/// `EstadoEntitlement` se prueba aparte con un `PuertoCompras` falso
|
|
/// (`estado_entitlement_test.dart`).
|
|
|
|
/// Fake [InAppPurchase]: deja que cada test empuje lotes por
|
|
/// [purchaseStream] a mano. `noSuchMethod` cubre el resto de la API del
|
|
/// plugin, que estos tests no ejercitan.
|
|
class _InAppPurchaseFalso implements InAppPurchase {
|
|
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
|
int restauracionesPedidas = 0;
|
|
bool disponible = true;
|
|
final completadas = <PurchaseDetails>[];
|
|
|
|
@override
|
|
Future<bool> isAvailable() async => disponible;
|
|
|
|
@override
|
|
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
|
|
|
@override
|
|
Future<void> restorePurchases({String? applicationUserName}) async {
|
|
restauracionesPedidas++;
|
|
}
|
|
|
|
@override
|
|
Future<void> completePurchase(PurchaseDetails purchase) async {
|
|
completadas.add(purchase);
|
|
}
|
|
|
|
void emitir(List<PurchaseDetails> compras) => _compras.add(compras);
|
|
|
|
Future<void> dispose() => _compras.close();
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
|
|
purchaseID: 'compra-1',
|
|
productID: ServicioComprasPlayBilling.idProducto,
|
|
verificationData: PurchaseVerificationData(
|
|
localVerificationData: 'local',
|
|
serverVerificationData: 'server',
|
|
source: 'google_play',
|
|
),
|
|
transactionDate: null,
|
|
status: status,
|
|
);
|
|
|
|
/// A Play Billing past purchase as `queryPastPurchases` returns it.
|
|
GooglePlayPurchaseDetails _compraPasada(
|
|
PurchaseStatus status, {
|
|
String productId = ServicioComprasPlayBilling.idProducto,
|
|
}) => GooglePlayPurchaseDetails(
|
|
purchaseID: 'GPA.1',
|
|
productID: productId,
|
|
verificationData: PurchaseVerificationData(
|
|
localVerificationData: '{}',
|
|
serverVerificationData: 'token',
|
|
source: 'google_play',
|
|
),
|
|
transactionDate: '0',
|
|
status: status,
|
|
billingClientPurchase: PurchaseWrapper(
|
|
orderId: 'GPA.1',
|
|
packageName: 'es.freetimelab.pluriwave',
|
|
purchaseTime: 0,
|
|
purchaseToken: 'token',
|
|
signature: 'firma',
|
|
products: <String>[productId],
|
|
isAutoRenewing: false,
|
|
originalJson: '{}',
|
|
isAcknowledged: true,
|
|
purchaseState: PurchaseStateWrapper.purchased,
|
|
),
|
|
);
|
|
|
|
IAPError _errorBilling() => IAPError(
|
|
source: 'google_play',
|
|
code: 'restore_transactions_failed',
|
|
message: 'BillingResponse.serviceUnavailable',
|
|
);
|
|
|
|
void main() {
|
|
group('eventoDesdeEstadoCompra', () {
|
|
test('purchased -> comprada', () {
|
|
expect(
|
|
eventoDesdeEstadoCompra(PurchaseStatus.purchased).tipo,
|
|
TipoEventoCompra.comprada,
|
|
);
|
|
});
|
|
|
|
test('restored -> restaurada', () {
|
|
expect(
|
|
eventoDesdeEstadoCompra(PurchaseStatus.restored).tipo,
|
|
TipoEventoCompra.restaurada,
|
|
);
|
|
});
|
|
|
|
test('canceled -> cancelada', () {
|
|
expect(
|
|
eventoDesdeEstadoCompra(PurchaseStatus.canceled).tipo,
|
|
TipoEventoCompra.cancelada,
|
|
);
|
|
});
|
|
|
|
test('pending -> pendiente', () {
|
|
expect(
|
|
eventoDesdeEstadoCompra(PurchaseStatus.pending).tipo,
|
|
TipoEventoCompra.pendiente,
|
|
);
|
|
});
|
|
|
|
test('error conserva el mensaje diagnostico', () {
|
|
final evento = eventoDesdeEstadoCompra(
|
|
PurchaseStatus.error,
|
|
mensaje: 'BILLING_UNAVAILABLE',
|
|
);
|
|
|
|
expect(evento.tipo, TipoEventoCompra.error);
|
|
expect(evento.mensaje, 'BILLING_UNAVAILABLE');
|
|
});
|
|
});
|
|
|
|
group('ServicioComprasPlayBilling.purchaseStream', () {
|
|
test('un lote vacio emite noEncontrada (restaurar sin compras)', () async {
|
|
final iap = _InAppPurchaseFalso();
|
|
addTearDown(iap.dispose);
|
|
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
|
addTearDown(servicio.dispose);
|
|
|
|
final tipos = <TipoEventoCompra>[];
|
|
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
|
|
addTearDown(sub.cancel);
|
|
|
|
await servicio.restaurar();
|
|
iap.emitir(const <PurchaseDetails>[]);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
// Sin este evento `EstadoEntitlement._compraEnCurso` se queda en `true`
|
|
// para siempre y `hoja_premium.dart` deshabilita AMBOS botones
|
|
// (comprar y restaurar): el usuario no puede pagar.
|
|
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.noEncontrada]);
|
|
expect(iap.restauracionesPedidas, 1);
|
|
});
|
|
|
|
test('un lote con compras NO emite noEncontrada', () async {
|
|
final iap = _InAppPurchaseFalso();
|
|
addTearDown(iap.dispose);
|
|
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
|
addTearDown(servicio.dispose);
|
|
|
|
final tipos = <TipoEventoCompra>[];
|
|
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
|
|
addTearDown(sub.cancel);
|
|
|
|
iap.emitir(<PurchaseDetails>[_compraFalsa(PurchaseStatus.restored)]);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.restaurada]);
|
|
});
|
|
|
|
test('completa las compras pendientes de confirmar', () async {
|
|
final iap = _InAppPurchaseFalso();
|
|
addTearDown(iap.dispose);
|
|
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
|
addTearDown(servicio.dispose);
|
|
|
|
final compra = _compraFalsa(PurchaseStatus.purchased)
|
|
..pendingCompletePurchase = true;
|
|
iap.emitir(<PurchaseDetails>[compra]);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(iap.completadas, <PurchaseDetails>[compra]);
|
|
});
|
|
});
|
|
|
|
group('ServicioComprasPlayBilling.consultarPropiedad (verificacion '
|
|
'silenciosa)', () {
|
|
late _InAppPurchaseFalso iap;
|
|
|
|
setUp(() => iap = _InAppPurchaseFalso());
|
|
tearDown(() => iap.dispose());
|
|
|
|
ServicioComprasPlayBilling servicioCon(
|
|
Future<QueryPurchaseDetailsResponse> Function() consulta, {
|
|
Duration limite = const Duration(seconds: 10),
|
|
}) {
|
|
final servicio = ServicioComprasPlayBilling(
|
|
inAppPurchase: iap,
|
|
consultarComprasPasadas: consulta,
|
|
limiteConsultaPropiedad: limite,
|
|
);
|
|
addTearDown(servicio.dispose);
|
|
return servicio;
|
|
}
|
|
|
|
test('producto comprado -> poseida', () async {
|
|
final servicio = servicioCon(
|
|
() async => QueryPurchaseDetailsResponse(
|
|
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.poseida,
|
|
);
|
|
});
|
|
|
|
test('respuesta correcta sin el producto -> noPoseida', () async {
|
|
final servicio = servicioCon(
|
|
() async => QueryPurchaseDetailsResponse(
|
|
pastPurchases: [
|
|
_compraPasada(PurchaseStatus.purchased, productId: 'otro'),
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.noPoseida,
|
|
);
|
|
});
|
|
|
|
test('respuesta con error y sin producto -> desconocido', () async {
|
|
final servicio = servicioCon(
|
|
() async => QueryPurchaseDetailsResponse(
|
|
pastPurchases: const [],
|
|
error: _errorBilling(),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.desconocido,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'respuesta con error parcial pero con el producto -> poseida',
|
|
() async {
|
|
final servicio = servicioCon(
|
|
() async => QueryPurchaseDetailsResponse(
|
|
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
|
|
error: _errorBilling(),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.poseida,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('compra pendiente -> desconocido (nunca revoca)', () async {
|
|
final servicio = servicioCon(
|
|
() async => QueryPurchaseDetailsResponse(
|
|
pastPurchases: [_compraPasada(PurchaseStatus.pending)],
|
|
),
|
|
);
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.desconocido,
|
|
);
|
|
});
|
|
|
|
test('billing no disponible -> desconocido, sin consultar', () async {
|
|
var consultas = 0;
|
|
iap.disponible = false;
|
|
final servicio = servicioCon(() async {
|
|
consultas++;
|
|
return QueryPurchaseDetailsResponse(pastPurchases: const []);
|
|
});
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.desconocido,
|
|
);
|
|
expect(consultas, 0);
|
|
});
|
|
|
|
test('excepcion -> desconocido', () async {
|
|
final servicio = servicioCon(
|
|
() async => throw Exception('BillingClient desconectado'),
|
|
);
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.desconocido,
|
|
);
|
|
});
|
|
|
|
test('timeout -> desconocido', () async {
|
|
final servicio = servicioCon(
|
|
() => Completer<QueryPurchaseDetailsResponse>().future,
|
|
limite: const Duration(milliseconds: 10),
|
|
);
|
|
|
|
expect(
|
|
await servicio.consultarPropiedad(),
|
|
ResultadoVerificacionLicencia.desconocido,
|
|
);
|
|
});
|
|
|
|
test('es silenciosa: no emite eventos de compra ni usa '
|
|
'restorePurchases', () async {
|
|
final servicio = servicioCon(
|
|
() async => QueryPurchaseDetailsResponse(pastPurchases: const []),
|
|
);
|
|
final eventos = <EventoCompra>[];
|
|
final sub = servicio.eventos.listen(eventos.add);
|
|
addTearDown(sub.cancel);
|
|
|
|
await servicio.consultarPropiedad();
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(eventos, isEmpty);
|
|
expect(iap.restauracionesPedidas, 0);
|
|
});
|
|
});
|
|
|
|
test('idProducto es el identificador unico no-consumible', () {
|
|
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
|
|
});
|
|
}
|