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.
257 lines
8.3 KiB
Dart
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));
|
|
});
|
|
});
|
|
}
|