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/modelos/emisora.dart'; import 'package:pluriwave/servicios/servicio_radio.dart'; /// Characterisation tests for all 8 existing `ServicioRadio` station calls, /// written against the CURRENT (unmodified) `_get`/`registrarClick` — green /// by construction, per design ADR-4's strict-TDD sequence: "naive 'the test /// must fail first' does not apply to characterisation." /// /// Design's non-negotiable ordering rule: this file MUST be green BEFORE the /// `_getJson` transport extraction begins, and re-run byte-identical green /// AFTER it (task 7.6) — that re-run is the extraction's actual proof. /// /// `test/servicios/servicio_radio_test.dart` is intentionally NOT modified /// by this work unit; its passing untouched is itself a signal that /// transport was not disturbed. void main() { // Deliberately shuffled bitrate/clickcount/votes: by bitrate desc, `s_b`, // `s_c` and `s_d` tie at 300; `s_d` wins the tie on clickcount; `s_c` beats // `s_b` on votes. This exercises every level of `_compararCalidad` and is // what makes the extraction safe — a sort that silently sinks into // transport would pass every other assertion below but fail this one. List> fixtureDesordenada() => [ { 'stationuuid': 's_a', 'name': 'A', 'url_resolved': 'https://a.example/audio', 'bitrate': 100, 'clickcount': 10, 'votes': 1, }, { 'stationuuid': 's_b', 'name': 'B', 'url_resolved': 'https://b.example/audio', 'bitrate': 300, 'clickcount': 5, 'votes': 50, }, { 'stationuuid': 's_c', 'name': 'C', 'url_resolved': 'https://c.example/audio', 'bitrate': 300, 'clickcount': 5, 'votes': 999, }, { 'stationuuid': 's_d', 'name': 'D', 'url_resolved': 'https://d.example/audio', 'bitrate': 300, 'clickcount': 80, 'votes': 1, }, { 'stationuuid': 's_e', 'name': 'E', 'url_resolved': 'https://e.example/audio', 'bitrate': 50, 'clickcount': 999, 'votes': 999, }, ]; const ordenEsperado = ['s_d', 's_c', 's_b', 's_a', 's_e']; /// Runs [accion] against a `ServicioRadio` whose single mock host always /// answers with [fixtureDesordenada], and returns both the captured /// request and the method's return value so callers can assert transport /// concerns (path/params/headers) and result ordering together. Future<(http.Request, List)> ejecutar( Future> Function(ServicioRadio) accion, ) async { late http.Request solicitud; final servicio = ServicioRadio( cliente: MockClient((request) async { solicitud = request; return http.Response( jsonEncode(fixtureDesordenada()), 200, headers: {'content-type': 'application/json'}, ); }), servidores: const ['host.api.radio-browser.info'], retryDelay: Duration.zero, ); final resultado = await accion(servicio); return (solicitud, resultado); } group('ServicioRadio — 7 métodos basados en _get', () { test('obtenerPopulares: path, filtros de transporte y orden', () async { final (solicitud, emisoras) = await ejecutar( (s) => s.obtenerPopulares(limit: 7, offset: 3), ); expect(solicitud.url.path, '/json/stations/search'); expect(solicitud.url.queryParameters['lastcheckok'], '1'); expect(solicitud.url.queryParameters['hidebroken'], 'true'); expect(solicitud.url.queryParameters['order'], 'bitrate'); expect(solicitud.url.queryParameters['reverse'], 'true'); expect(solicitud.url.queryParameters['limit'], '7'); expect(solicitud.url.queryParameters['offset'], '3'); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); }); test('obtenerTendencias: path con limit embebido, sin order/reverse, ' 'filtros de transporte y orden', () async { final (solicitud, emisoras) = await ejecutar( (s) => s.obtenerTendencias(limit: 12), ); expect(solicitud.url.path, '/json/stations/topclick/12'); expect(solicitud.url.queryParameters['lastcheckok'], '1'); expect(solicitud.url.queryParameters['hidebroken'], 'true'); // topclick is pre-sorted by the endpoint itself — no order/reverse // is ever sent for this method. expect(solicitud.url.queryParameters.containsKey('order'), isFalse); expect(solicitud.url.queryParameters.containsKey('reverse'), isFalse); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); }); test('buscarPorNombre: path, filtros de transporte y orden', () async { final (solicitud, emisoras) = await ejecutar( (s) => s.buscarPorNombre('radio horizonte', limit: 9, offset: 2), ); expect(solicitud.url.path, '/json/stations/search'); expect(solicitud.url.queryParameters['name'], 'radio horizonte'); expect(solicitud.url.queryParameters['lastcheckok'], '1'); expect(solicitud.url.queryParameters['hidebroken'], 'true'); expect(solicitud.url.queryParameters['order'], 'bitrate'); expect(solicitud.url.queryParameters['reverse'], 'true'); expect(solicitud.url.queryParameters['limit'], '9'); expect(solicitud.url.queryParameters['offset'], '2'); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); }); test('buscarPorPais: path, filtros de transporte y orden', () async { final (solicitud, emisoras) = await ejecutar( (s) => s.buscarPorPais('ES', limit: 11, offset: 4), ); expect(solicitud.url.path, '/json/stations/bycountrycodeexact/ES'); expect(solicitud.url.queryParameters['lastcheckok'], '1'); expect(solicitud.url.queryParameters['hidebroken'], 'true'); expect(solicitud.url.queryParameters['order'], 'bitrate'); expect(solicitud.url.queryParameters['reverse'], 'true'); expect(solicitud.url.queryParameters['limit'], '11'); expect(solicitud.url.queryParameters['offset'], '4'); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); }); test('buscarPorIdioma: path, filtros de transporte y orden', () async { final (solicitud, emisoras) = await ejecutar( (s) => s.buscarPorIdioma('spanish', limit: 6, offset: 1), ); expect(solicitud.url.path, '/json/stations/bylanguageexact/spanish'); expect(solicitud.url.queryParameters['lastcheckok'], '1'); expect(solicitud.url.queryParameters['hidebroken'], 'true'); expect(solicitud.url.queryParameters['order'], 'bitrate'); expect(solicitud.url.queryParameters['reverse'], 'true'); expect(solicitud.url.queryParameters['limit'], '6'); expect(solicitud.url.queryParameters['offset'], '1'); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); }); test('buscarPorTag: path, filtros de transporte y orden', () async { final (solicitud, emisoras) = await ejecutar( (s) => s.buscarPorTag('jazz', limit: 8, offset: 0), ); expect(solicitud.url.path, '/json/stations/bytagexact/jazz'); expect(solicitud.url.queryParameters['lastcheckok'], '1'); expect(solicitud.url.queryParameters['hidebroken'], 'true'); expect(solicitud.url.queryParameters['order'], 'bitrate'); expect(solicitud.url.queryParameters['reverse'], 'true'); expect(solicitud.url.queryParameters['limit'], '8'); expect(solicitud.url.queryParameters['offset'], '0'); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); }); test('buscar: combina name/countrycode/language/tag, filtros de transporte ' 'y orden', () async { final (solicitud, emisoras) = await ejecutar( (s) => s.buscar( nombre: 'rock', pais: 'ES', idioma: 'spanish', tag: 'indie', limit: 15, offset: 5, ), ); expect(solicitud.url.path, '/json/stations/search'); expect(solicitud.url.queryParameters['name'], 'rock'); expect(solicitud.url.queryParameters['countrycode'], 'ES'); expect(solicitud.url.queryParameters['language'], 'spanish'); expect(solicitud.url.queryParameters['tag'], 'indie'); expect(solicitud.url.queryParameters['lastcheckok'], '1'); expect(solicitud.url.queryParameters['hidebroken'], 'true'); expect(solicitud.url.queryParameters['order'], 'bitrate'); expect(solicitud.url.queryParameters['reverse'], 'true'); expect(solicitud.url.queryParameters['limit'], '15'); expect(solicitud.url.queryParameters['offset'], '5'); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(emisoras.map((e) => e.uuid), equals(ordenEsperado)); }); }); group('ServicioRadio — obtenerPaises (usa _getJson, no _get)', () { test('consulta /json/countries sin lastcheckok ni order; conserva ' 'hidebroken; parsea stationcount desde un string JSON', () async { late http.Request solicitud; final servicio = ServicioRadio( cliente: MockClient((request) async { solicitud = request; return http.Response( jsonEncode([ {'name': 'Spain', 'iso_3166_1': 'es', 'stationcount': '482'}, {'name': 'Argentina', 'iso_3166_1': 'ar', 'stationcount': '120'}, ]), 200, headers: {'content-type': 'application/json'}, ); }), servidores: const ['host.api.radio-browser.info'], retryDelay: Duration.zero, ); final paises = await servicio.obtenerPaises(); expect(solicitud.url.path, '/json/countries'); // The station-only filter and the station-only sort must NOT reach // this endpoint — that is the entire point of the `_getJson` // extraction (spec scenario "Countries request omits the // station-only filter"). expect(solicitud.url.queryParameters.containsKey('lastcheckok'), isFalse); expect(solicitud.url.queryParameters.containsKey('order'), isFalse); expect(solicitud.url.queryParameters.containsKey('reverse'), isFalse); // Inherited from `_uri`, unchanged — desirable here too (Engram id // 2500): the endpoint's own default is `false`. expect(solicitud.url.queryParameters['hidebroken'], 'true'); expect(solicitud.headers['User-Agent'], isNotEmpty); expect(paises.map((p) => p.codigoIso), ['ES', 'AR']); // `stationcount` arrived as a JSON STRING ("482") — this proves the // whole pipeline (not just `PaisRadio.fromApi` in isolation) parses // it without a cast error. expect(paises.map((p) => p.numeroEmisoras), [482, 120]); }); }); group('ServicioRadio — registrarClick (8º, no usa _get)', () { test( 'pega al path correcto con un User-Agent no vacío ' '(literal desactualizado, fuera de alcance: no se fija su valor)', () async { late http.Request solicitud; final servicio = ServicioRadio( cliente: MockClient((request) async { solicitud = request; return http.Response('', 200); }), servidores: const ['host.api.radio-browser.info'], retryDelay: Duration.zero, ); await servicio.registrarClick('uuid-click'); expect(solicitud.url.path, '/json/url/uuid-click'); expect(solicitud.headers['User-Agent'], isNotEmpty); // Deliberately NOT asserting the exact User-Agent value: it hardcodes // a stale 'PluriWave/0.1.0 (...)' literal (known, out-of-scope // defect — see `_resolverUserAgent()`'s own doc comment). Pinning a // known-wrong value would convert a bug into a contract. }, ); }); }