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:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||
|
||||
/// Android Auto (headless) trigger of the silent license check: the
|
||||
/// `registrar*` seam `main.dart` wires, exercised without a real
|
||||
/// `PluriWaveAudioHandler`.
|
||||
void main() {
|
||||
late int invalidaciones;
|
||||
|
||||
setUp(() {
|
||||
invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
registrarInvalidacionArbolAuto(() {});
|
||||
registrarVerificacionLicenciaAuto(null);
|
||||
});
|
||||
|
||||
test('sin verificador registrado es un no-op que nunca lanza', () async {
|
||||
registrarVerificacionLicenciaAuto(null);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test('una revocacion invalida el arbol de Auto', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => CambioLicencia.revocada);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 1);
|
||||
});
|
||||
|
||||
test('un desbloqueo silencioso tambien invalida el arbol', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => CambioLicencia.desbloqueada);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 1);
|
||||
});
|
||||
|
||||
test('sin cambios no invalida nada', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => CambioLicencia.sinCambios);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test('un fallo del verificador se traga en silencio', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => throw StateError('boom'));
|
||||
|
||||
await expectLater(dispararVerificacionLicenciaAuto(), completes);
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'no retiene al llamador: devuelve antes de que la consulta acabe',
|
||||
() async {
|
||||
final pendiente = Completer<CambioLicencia>();
|
||||
registrarVerificacionLicenciaAuto(() => pendiente.future);
|
||||
|
||||
// The browse path calls this unawaited; the returned future being
|
||||
// pending here proves the verification runs in the background.
|
||||
var terminado = false;
|
||||
unawaited(
|
||||
dispararVerificacionLicenciaAuto().then((_) => terminado = true),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(terminado, isFalse);
|
||||
|
||||
pendiente.complete(CambioLicencia.revocada);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(terminado, isTrue);
|
||||
expect(invalidaciones, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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