Files
pluriwave/test/servicios/servicio_radio_test.dart
FreeTLab 9b8209ac93
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m34s
fix(radio): discover live API mirrors instead of hardcoding dead ones
Two of the three Radio Browser hosts this client shipped no longer resolve.
The retry loop rotates de1 -> nl1 -> at1, so once the first attempt failed
for any transient reason the remaining two were guaranteed to fail as well:
the retries meant to add resilience had become a dead end, and a single blip
surfaced as "No connection to the radio API" with a healthy API and a healthy
network. The live mirror list confirms only one server remains:

  [{"ip":"91.98.4.78","name":"de1.api.radio-browser.info"},
   {"ip":"2a01:4f8:1c1d:699::1","name":"de1.api.radio-browser.info"}]

The API docs say exactly what this code was doing wrong: "Never use a direct
link to a single new server. It is much better to get a list of the servers",
pointing clients at all.api.radio-browser.info to enumerate what exists.

Seed with that round-robin host plus de1, then resolve the real list from
/json/servers once per instance and rotate over that. Discovery shares one
in-flight request across concurrent callers, because the home screen loads
two lists at once through Future.wait, and any failure silently leaves the
seed list in place — it still contains a working host, so a failed discovery
must never be worse than not trying. Explicitly injected servers disable
discovery so callers can still pin a mirror.

Build the User-Agent from the running package too. The API asks clients to
identify themselves, and this header claimed PluriWave/0.1.0 while the app
shipped 1.1.x. A literal cannot stay correct here — CI bumps the version on
every single release — so read it via package_info_plus, already a dependency
used in three other places. If package info is unavailable the product name
goes out alone rather than a made-up version, and resolution never throws: a
header must not be able to fail a request.
2026-07-26 01:26:10 +02:00

257 lines
8.3 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pluriwave/servicios/servicio_radio.dart';
void main() {
group('ServicioRadio retry + rotación', () {
test(
'reintenta con otro host cuando el primero falla y recupera en el segundo',
() async {
final hostsSolicitados = <String>[];
final servicio = ServicioRadio(
cliente: MockClient((request) async {
hostsSolicitados.add(request.url.host);
if (request.url.host == 'host-1.api.radio-browser.info') {
return http.Response('fallo', 500);
}
return http.Response(
jsonEncode([
{
'stationuuid': 'uuid-ok',
'name': 'Radio Recuperada',
'url_resolved': 'https://stream.recuperada/audio',
},
]),
200,
headers: {'content-type': 'application/json'},
);
}),
servidores: const [
'host-1.api.radio-browser.info',
'host-2.api.radio-browser.info',
],
maxIntentos: 3,
retryDelay: Duration.zero,
);
final emisoras = await servicio.obtenerPopulares(limit: 1);
expect(emisoras, hasLength(1));
expect(emisoras.first.uuid, 'uuid-ok');
expect(
hostsSolicitados,
equals([
'host-1.api.radio-browser.info',
'host-2.api.radio-browser.info',
]),
);
},
);
test('corta al llegar al tope de intentos y propaga error final', () async {
var intentos = 0;
final servicio = ServicioRadio(
cliente: MockClient((request) async {
intentos += 1;
throw http.ClientException('sin red', request.url);
}),
servidores: const ['host-unico.api.radio-browser.info'],
maxIntentos: 2,
retryDelay: Duration.zero,
);
await expectLater(
servicio.obtenerPopulares(limit: 1),
throwsA(isA<Exception>()),
);
expect(intentos, 2);
});
test('prioriza emisoras verificadas de mayor bitrate', () async {
final servicio = ServicioRadio(
cliente: MockClient((request) async {
expect(request.url.queryParameters['order'], 'bitrate');
expect(request.url.queryParameters['reverse'], 'true');
return http.Response(
jsonEncode([
{
'stationuuid': 'baja',
'name': 'Baja',
'url_resolved': 'https://stream.example/low',
'bitrate': 64,
'votes': 999,
},
{
'stationuuid': 'alta',
'name': 'Alta',
'url_resolved': 'https://stream.example/high',
'bitrate': 320,
'votes': 1,
},
]),
200,
headers: {'content-type': 'application/json'},
);
}),
servidores: const ['host.api.radio-browser.info'],
retryDelay: Duration.zero,
);
final emisoras = await servicio.buscar(nombre: 'radio');
expect(emisoras.map((e) => e.uuid), equals(['alta', 'baja']));
});
});
// ---------------------------------------------------------------------------
// Server discovery
//
// The API docs are explicit: "Never use a direct link to a single new server.
// It is much better to get a list of the servers", because mirror names come
// and go. Two of the three names this client used to hardcode (nl1, at1) no
// longer resolve, which turned both retries into guaranteed failures.
// ---------------------------------------------------------------------------
group('ServicioRadio — User-Agent', () {
test('identifica al cliente sin inventarse una versión', () async {
String? agente;
final servicio = ServicioRadio(
cliente: MockClient((request) async {
agente = request.headers['User-Agent'];
return http.Response(jsonEncode(const []), 200);
}),
servidores: const ['host.api.radio-browser.info'],
retryDelay: Duration.zero,
);
await servicio.obtenerPopulares(limit: 1);
expect(agente, isNotNull);
expect(agente, contains('PluriWave'));
// The version comes from the build at runtime; CI bumps it on every
// release, so a literal here would be stale within one build. It used to
// claim 0.1.0 while the app shipped 1.1.x.
expect(agente, isNot(contains('0.1.0')));
});
});
group('ServicioRadio — descubrimiento de servidores', () {
List<int> respuestaEmisoras() => utf8.encode(
jsonEncode([
{
'stationuuid': 'uuid-ok',
'name': 'Radio OK',
'url_resolved': 'https://stream.ok/audio',
},
]),
);
test('no arranca con hosts muertos: usa el alias round-robin', () {
expect(
ServicioRadio.servidoresSemilla.first,
equals('all.api.radio-browser.info'),
);
expect(
ServicioRadio.servidoresSemilla,
isNot(contains('nl1.api.radio-browser.info')),
);
expect(
ServicioRadio.servidoresSemilla,
isNot(contains('at1.api.radio-browser.info')),
);
});
test('consulta /json/servers y usa los nombres descubiertos', () async {
final hosts = <String>[];
final servicio = ServicioRadio(
cliente: MockClient((request) async {
hosts.add(request.url.host);
if (request.url.path == '/json/servers') {
return http.Response(
jsonEncode([
{'ip': '1.2.3.4', 'name': 'descubierto.api.radio-browser.info'},
{'ip': '::1', 'name': 'descubierto.api.radio-browser.info'},
]),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response.bytes(respuestaEmisoras(), 200);
}),
retryDelay: Duration.zero,
);
final emisoras = await servicio.obtenerPopulares(limit: 1);
expect(emisoras, hasLength(1));
expect(hosts.first, equals('all.api.radio-browser.info'));
// Duplicate names (one per IP family) collapse to a single host.
expect(hosts.sublist(1), equals(['descubierto.api.radio-browser.info']));
});
test('descubrimiento fallido cae a la semilla sin romper', () async {
final hosts = <String>[];
final servicio = ServicioRadio(
cliente: MockClient((request) async {
hosts.add(request.url.host);
if (request.url.path == '/json/servers') {
throw http.ClientException('sin red', request.url);
}
return http.Response.bytes(respuestaEmisoras(), 200);
}),
retryDelay: Duration.zero,
);
final emisoras = await servicio.obtenerPopulares(limit: 1);
expect(emisoras, hasLength(1));
expect(hosts.last, isIn(ServicioRadio.servidoresSemilla));
});
test('el descubrimiento ocurre una sola vez por instancia', () async {
var llamadasServers = 0;
final servicio = ServicioRadio(
cliente: MockClient((request) async {
if (request.url.path == '/json/servers') {
llamadasServers += 1;
return http.Response(
jsonEncode([
{'ip': '1.2.3.4', 'name': 'uno.api.radio-browser.info'},
]),
200,
);
}
return http.Response.bytes(respuestaEmisoras(), 200);
}),
retryDelay: Duration.zero,
);
await servicio.obtenerPopulares(limit: 1);
await servicio.obtenerPopulares(limit: 1);
expect(llamadasServers, equals(1));
});
test('servidores inyectados desactivan el descubrimiento', () async {
var llamadasServers = 0;
final servicio = ServicioRadio(
cliente: MockClient((request) async {
if (request.url.path == '/json/servers') {
llamadasServers += 1;
}
return http.Response.bytes(respuestaEmisoras(), 200);
}),
servidores: const ['host.api.radio-browser.info'],
retryDelay: Duration.zero,
);
await servicio.obtenerPopulares(limit: 1);
expect(llamadasServers, equals(0));
});
});
}