fix(compras): revocar el PRO tras un reembolso sin penalizar a quien pago
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 4m8s

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:
Javier Bautista Fernández
2026-09-18 11:45:43 +02:00
parent d7366bbf99
commit 0b7919e72e
14 changed files with 1163 additions and 17 deletions
+189 -2
View File
@@ -2,6 +2,8 @@ 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
@@ -17,8 +19,12 @@ import 'package:pluriwave/servicios/servicio_compras.dart';
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;
@@ -52,6 +58,40 @@ PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
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', () {
@@ -137,8 +177,8 @@ void main() {
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final compra =
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
final compra = _compraFalsa(PurchaseStatus.purchased)
..pendingCompletePurchase = true;
iap.emitir(<PurchaseDetails>[compra]);
await Future<void>.delayed(Duration.zero);
@@ -146,6 +186,153 @@ void main() {
});
});
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');
});