diff --git a/lib/utils/api_request.dart b/lib/utils/api_request.dart index 094dcfa1..87e3582a 100644 --- a/lib/utils/api_request.dart +++ b/lib/utils/api_request.dart @@ -3,10 +3,26 @@ import 'dart:io'; import 'package:app/exceptions/exceptions.dart'; import 'package:app/utils/preferences.dart' as preferences; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as Http; enum HttpMethod { get, post, patch, put, delete } +/// The HTTP client used by [request] and the per-method helpers below. +/// Production code uses a single shared [Http.Client]. Tests can swap +/// in their own client (typically a `MockClient` from +/// `package:http/testing.dart`) via [setHttpClientForTesting]. +Http.Client _client = Http.Client(); + +/// Replace the HTTP client used by [request]. Test-only. +@visibleForTesting +void setHttpClientForTesting(Http.Client client) => _client = client; + +/// Reset the HTTP client to a fresh default. Test-only — call from +/// `tearDown` so the override doesn't leak across tests. +@visibleForTesting +void resetHttpClientForTesting() => _client = Http.Client(); + Future request( HttpMethod method, String path, { @@ -26,31 +42,31 @@ Future request( switch (method) { case HttpMethod.get: - response = await Http.get(uri, headers: headers); + response = await _client.get(uri, headers: headers); break; case HttpMethod.post: - response = await Http.post( + response = await _client.post( uri, headers: headers, body: json.encode(data), ); break; case HttpMethod.patch: - response = await Http.patch( + response = await _client.patch( uri, headers: headers, body: json.encode(data), ); break; case HttpMethod.put: - response = await Http.put( + response = await _client.put( uri, headers: headers, body: json.encode(data), ); break; case HttpMethod.delete: - response = await Http.delete( + response = await _client.delete( uri, headers: headers, body: json.encode(data), diff --git a/test/helpers/api_test_setup.dart b/test/helpers/api_test_setup.dart new file mode 100644 index 00000000..fb04b343 --- /dev/null +++ b/test/helpers/api_test_setup.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:app/utils/api_request.dart' as api; +import 'package:app/utils/preferences.dart' as preferences; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart' as Http; +import 'package:http/testing.dart'; + +/// One-time bring-up for any test that touches `api_request.dart`: +/// initialises `GetStorage` (which is needed by `preferences`) and +/// stubs the `path_provider` channel so it works in widget tests. +/// +/// Each test isolate gets its own temp directory so the +/// `./Preferences.gs` file lock doesn't collide when several provider +/// test files run in parallel. +/// +/// Call from `setUpAll`. +Future initApiTestEnvironment() async { + TestWidgetsFlutterBinding.ensureInitialized(); + final tmp = await Directory.systemTemp.createTemp('koel_test_'); + // GetStorage.init() pulls the docs dir via path_provider, which has + // no plugin implementation in widget tests. Stub the channel so it + // falls back to a writable location. + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => tmp.path); + await GetStorage.init('Preferences'); +} + +/// Sets stable values for the preferences the api_request layer reads. +/// Pair with [tearDownApiTest] to clear them again. +void setUpApiTest() { + preferences.host = 'https://koel.test'; + preferences.apiToken = 'tok'; +} + +/// Resets the http client and clears preferences. Call from `tearDown`. +void tearDownApiTest() { + preferences.host = null; + preferences.apiToken = null; + api.resetHttpClientForTesting(); +} + +/// Records every outgoing request and replies with a configurable +/// status / body. Each captured entry exposes the URL, method and +/// decoded JSON body for assertions. +class CapturingClient { + final List requests = []; + int _status = 200; + String _body = '{}'; + + late final MockClient client = MockClient((request) async { + final raw = request.body; + requests.add(CapturedRequest( + method: request.method, + url: request.url.toString(), + // Lowercase keys: http's internal request.headers is + // case-insensitive, but a plain Map copy isn't + // — normalise so test lookups don't depend on the package's + // storage casing. + headers: { + for (final entry in request.headers.entries) + entry.key.toLowerCase(): entry.value, + }, + rawBody: raw, + )); + return Http.Response(_body, _status); + }); + + /// Sets the response the next (and subsequent) requests will see. + void willReturn({int status = 200, Object? json}) { + _status = status; + _body = json == null ? '' : jsonEncode(json); + } + + /// Sets a status with a literal text body (use for non-JSON / 4xx / + /// 5xx fixtures). + void willReturnRaw({int status = 200, String body = ''}) { + _status = status; + _body = body; + } + + /// Installs this client as the api_request module's active client. + void install() => api.setHttpClientForTesting(client); +} + +class CapturedRequest { + final String method; + final String url; + final Map headers; + final String rawBody; + + CapturedRequest({ + required this.method, + required this.url, + required this.headers, + required this.rawBody, + }); + + Map? get jsonBody { + if (rawBody.isEmpty) return null; + final decoded = jsonDecode(rawBody); + return decoded is Map ? decoded : null; + } +} diff --git a/test/providers/album_provider_test.dart b/test/providers/album_provider_test.dart new file mode 100644 index 00000000..07df2edf --- /dev/null +++ b/test/providers/album_provider_test.dart @@ -0,0 +1,103 @@ +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/models/album.dart'; +import 'package:app/providers/album_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/api_test_setup.dart'; + +void main() { + setUpAll(initApiTestEnvironment); + setUp(setUpApiTest); + tearDown(tearDownApiTest); + + group('AlbumProvider.toggleFavorite', () { + test('flips optimistically, posts the right body, persists on 200', + () async { + final http = CapturingClient()..install(); + + final album = Album.fake(id: 7, favorite: false); + final provider = AlbumProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.toggleFavorite(album); + + // Single request, expected URL/method/body. + expect(http.requests, hasLength(1)); + final req = http.requests.single; + expect(req.method, 'POST'); + expect(req.url, 'https://koel.test/api/favorites/toggle'); + expect(req.jsonBody, {'type': 'album', 'id': 7}); + + // Optimistic flip is preserved on success. + expect(album.favorite, isTrue); + // One notify before the await, none after on success. + expect(notifyCount, 1); + }); + + test('rolls back, notifies again, and rethrows on failure', () async { + CapturingClient()..willReturn(status: 500)..install(); + + final album = Album.fake(id: 8, favorite: true); + final provider = AlbumProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await expectLater( + provider.toggleFavorite(album), + throwsA(isA()), + ); + + // The optimistic flip was reverted. + expect(album.favorite, isTrue); + // Two notifies: one after the optimistic flip, one after the + // rollback. + expect(notifyCount, 2); + }); + }); + + group('AlbumProvider.update', () { + test('PUTs the new fields and merges the server response', () async { + final http = CapturingClient() + ..willReturn(json: {'name': 'New Name', 'year': '1969'}) + ..install(); + + final album = Album.fake(id: 4) + ..name = 'Old Name' + ..year = 1965; + final provider = AlbumProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.update(album, name: 'New Name', year: 1969); + + final req = http.requests.single; + expect(req.method, 'PUT'); + expect(req.url, 'https://koel.test/api/albums/4'); + expect(req.jsonBody, {'name': 'New Name', 'year': 1969}); + + // Server response is the source of truth for the merged values. + expect(album.name, 'New Name'); + expect(album.year, 1969); + expect(notifyCount, 1); + }); + + test('treats a null year in the response as null on the model', + () async { + CapturingClient() + ..willReturn(json: {'name': 'No Year', 'year': null}) + ..install(); + + final album = Album.fake(id: 5) + ..name = 'X' + ..year = 2000; + + await AlbumProvider().update(album, name: 'No Year', year: null); + + expect(album.year, isNull); + }); + }); +} diff --git a/test/providers/artist_provider_test.dart b/test/providers/artist_provider_test.dart new file mode 100644 index 00000000..ca4ac418 --- /dev/null +++ b/test/providers/artist_provider_test.dart @@ -0,0 +1,78 @@ +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/models/artist.dart'; +import 'package:app/providers/artist_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/api_test_setup.dart'; + +void main() { + setUpAll(initApiTestEnvironment); + setUp(setUpApiTest); + tearDown(tearDownApiTest); + + group('ArtistProvider.toggleFavorite', () { + test('flips optimistically, posts the right body, persists on 200', + () async { + final http = CapturingClient()..install(); + + final artist = Artist.fake(id: 9, favorite: false); + final provider = ArtistProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.toggleFavorite(artist); + + final req = http.requests.single; + expect(req.method, 'POST'); + expect(req.url, 'https://koel.test/api/favorites/toggle'); + expect(req.jsonBody, {'type': 'artist', 'id': 9}); + + expect(artist.favorite, isTrue); + expect(notifyCount, 1); + }); + + test('rolls back, notifies again, and rethrows on failure', () async { + final http = CapturingClient()..willReturn(status: 500)..install(); + + final artist = Artist.fake(id: 10, favorite: true); + final provider = ArtistProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await expectLater( + provider.toggleFavorite(artist), + throwsA(isA()), + ); + + expect(artist.favorite, isTrue); + expect(http.requests, hasLength(1)); + expect(notifyCount, 2); + }); + }); + + group('ArtistProvider.update', () { + test('PUTs the new name and merges the server response', () async { + final http = CapturingClient() + ..willReturn(json: {'name': 'Renamed'}) + ..install(); + + final artist = Artist.fake(id: 11)..name = 'Old'; + final provider = ArtistProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.update(artist, name: 'Renamed'); + + final req = http.requests.single; + expect(req.method, 'PUT'); + expect(req.url, 'https://koel.test/api/artists/11'); + expect(req.jsonBody, {'name': 'Renamed'}); + + expect(artist.name, 'Renamed'); + expect(notifyCount, 1); + }); + }); +} diff --git a/test/providers/playable_provider_test.dart b/test/providers/playable_provider_test.dart new file mode 100644 index 00000000..43f44bfa --- /dev/null +++ b/test/providers/playable_provider_test.dart @@ -0,0 +1,125 @@ +import 'package:app/app_state.dart'; +import 'package:app/providers/playable_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/api_test_setup.dart'; + +Map _episodeJson(String id, {int track = 1}) => { + 'type': 'episodes', + 'id': id, + 'length': 600, + 'title': 'Episode $track', + 'podcast_id': 'pod-1', + 'podcast_title': 'Show', + 'podcast_author': 'Author', + 'episode_description': 'desc', + 'episode_image': 'https://example.com/ep.jpg', + 'episode_link': 'https://example.com/ep', + 'created_at': '2026-01-0${track}T00:00:00Z', + }; + +void main() { + setUpAll(initApiTestEnvironment); + setUp(() { + setUpApiTest(); + AppState.clear(); + }); + tearDown(tearDownApiTest); + + group('PlayableProvider.fetchForPodcast', () { + test( + 'GETs /podcasts//episodes and caches the result on first call', + () async { + final http = CapturingClient() + ..willReturn(json: [_episodeJson('e-1'), _episodeJson('e-2')]) + ..install(); + + final episodes = await PlayableProvider().fetchForPodcast('pod-1'); + + final req = http.requests.single; + expect(req.method, 'GET'); + expect(req.url, 'https://koel.test/api/podcasts/pod-1/episodes'); + expect(episodes.map((e) => e.id), ['e-1', 'e-2']); + expect(AppState.has(['podcast.episodes', 'pod-1']), isTrue); + }, + ); + + test('returns the cached value on a second call without re-fetching', + () async { + final http = CapturingClient() + ..willReturn(json: [_episodeJson('e-3')]) + ..install(); + final provider = PlayableProvider(); + + await provider.fetchForPodcast('pod-2'); + expect(http.requests, hasLength(1)); + + // Second call: cache should serve it, no extra request. + final cached = await provider.fetchForPodcast('pod-2'); + expect(http.requests, hasLength(1)); + expect(cached.single.id, 'e-3'); + }); + + test( + 'forceRefresh clears the cache, re-fetches, and notifies listeners', + () async { + final http = CapturingClient()..install(); + final provider = PlayableProvider(); + + // Seed the cache with the first response. + http.willReturn(json: [_episodeJson('old-1')]); + await provider.fetchForPodcast('pod-3'); + expect(http.requests, hasLength(1)); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + // Force refresh → expect a new GET and a notification. + http.willReturn(json: [_episodeJson('new-1'), _episodeJson('new-2')]); + final refreshed = + await provider.fetchForPodcast('pod-3', forceRefresh: true); + + expect(http.requests, hasLength(2)); + expect(refreshed.map((e) => e.id), ['new-1', 'new-2']); + expect(notifyCount, 1); + }, + ); + + test( + 'getUpdates appends ?refresh=1 to the URL', + () async { + final http = CapturingClient() + ..willReturn(json: [_episodeJson('e-4')]) + ..install(); + + await PlayableProvider().fetchForPodcast( + 'pod-4', + forceRefresh: true, + getUpdates: true, + ); + + expect( + http.requests.single.url, + 'https://koel.test/api/podcasts/pod-4/episodes?refresh=1', + ); + }, + ); + + test( + 'a non-forceful fetch does NOT notify listeners', + () async { + CapturingClient() + ..willReturn(json: [_episodeJson('e-5')]) + ..install(); + final provider = PlayableProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.fetchForPodcast('pod-5'); + + expect(notifyCount, 0); + }, + ); + }); +} diff --git a/test/providers/podcast_provider_test.dart b/test/providers/podcast_provider_test.dart new file mode 100644 index 00000000..8d328c14 --- /dev/null +++ b/test/providers/podcast_provider_test.dart @@ -0,0 +1,181 @@ +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/models/podcast.dart'; +import 'package:app/providers/podcast_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/api_test_setup.dart'; + +Map _podcastJson({ + required String id, + String title = 'A Show', + bool favorite = false, +}) => + { + 'id': id, + 'title': title, + 'url': 'https://example.com/feed.xml', + 'link': 'https://example.com', + 'description': 'desc', + 'author': 'Author', + 'image': 'https://example.com/img.jpg', + 'subscribed_at': '2026-01-01T00:00:00Z', + 'last_played_at': '2026-01-02T00:00:00Z', + 'state': {'progresses': {}}, + 'favorite': favorite, + }; + +void main() { + setUpAll(initApiTestEnvironment); + setUp(setUpApiTest); + tearDown(tearDownApiTest); + + group('PodcastProvider.toggleFavorite', () { + test('flips optimistically, posts the right body, persists on 200', + () async { + final http = CapturingClient()..install(); + + final podcast = Podcast.fake(id: 'p-1', favorite: false); + final provider = PodcastProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.toggleFavorite(podcast); + + final req = http.requests.single; + expect(req.method, 'POST'); + expect(req.url, 'https://koel.test/api/favorites/toggle'); + expect(req.jsonBody, {'type': 'podcast', 'id': 'p-1'}); + + expect(podcast.favorite, isTrue); + expect(notifyCount, 1); + }); + + test('rolls back, notifies again, and rethrows on failure', () async { + final http = CapturingClient()..willReturn(status: 500)..install(); + + final podcast = Podcast.fake(id: 'p-2', favorite: true); + final provider = PodcastProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await expectLater( + provider.toggleFavorite(podcast), + throwsA(isA()), + ); + + expect(podcast.favorite, isTrue); + expect(http.requests, hasLength(1)); + expect(notifyCount, 2); + }); + }); + + group('PodcastProvider.unsubscribePodcast', () { + test( + 'optimistically removes from list, then DELETEs and stays removed ' + 'on success', + () async { + final http = CapturingClient()..install(); + final provider = PodcastProvider(); + + // Seed via add(). + http.willReturn(json: _podcastJson(id: 'p-3')); + final podcast = await provider.add(url: 'https://feed.xml'); + expect(provider.podcasts, [podcast]); + + http.requests.clear(); + http.willReturn(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.unsubscribePodcast(podcast); + + expect(provider.podcasts, isEmpty); + // One notify before the await (optimistic), none after on success. + expect(notifyCount, 1); + expect(http.requests, hasLength(1)); + final req = http.requests.single; + expect(req.method, 'DELETE'); + expect(req.url, endsWith('/podcasts/p-3/subscriptions')); + }, + ); + + test( + 'restores the podcast and rethrows on a failed DELETE', + () async { + final http = CapturingClient()..install(); + final provider = PodcastProvider(); + + http.willReturn(json: _podcastJson(id: 'p-4')); + final podcast = await provider.add(url: 'https://feed.xml'); + + http.requests.clear(); + http.willReturn(status: 500); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await expectLater( + provider.unsubscribePodcast(podcast), + throwsA(isA()), + ); + + expect(provider.podcasts, [podcast]); + // Two notifies: optimistic remove, then restore. + expect(notifyCount, 2); + }, + ); + }); + + group('PodcastProvider.add', () { + test( + 'POSTs the URL, parses the resource, and appends it to the list', + () async { + final http = CapturingClient() + ..willReturn(json: _podcastJson(id: 'p-5', title: 'Added')) + ..install(); + final provider = PodcastProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + final podcast = await provider.add(url: 'https://feed.xml'); + + final req = http.requests.single; + expect(req.method, 'POST'); + expect(req.url, 'https://koel.test/api/podcasts'); + expect(req.jsonBody, {'url': 'https://feed.xml'}); + + expect(podcast.id, 'p-5'); + expect(podcast.title, 'Added'); + expect(provider.podcasts, [podcast]); + expect(notifyCount, 1); + }, + ); + }); + + group('PodcastProvider.fetchAll', () { + test('GETs /podcasts and replaces the in-memory list', () async { + final http = CapturingClient() + ..willReturn(json: [ + _podcastJson(id: 'p-6', title: 'One'), + _podcastJson(id: 'p-7', title: 'Two'), + ]) + ..install(); + final provider = PodcastProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.fetchAll(); + + final req = http.requests.single; + expect(req.method, 'GET'); + expect(req.url, 'https://koel.test/api/podcasts'); + expect(provider.podcasts.map((p) => p.id), ['p-6', 'p-7']); + expect(notifyCount, 1); + }); + }); +} diff --git a/test/providers/radio_station_provider_test.dart b/test/providers/radio_station_provider_test.dart new file mode 100644 index 00000000..b5dbece3 --- /dev/null +++ b/test/providers/radio_station_provider_test.dart @@ -0,0 +1,221 @@ +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/models/radio_station.dart'; +import 'package:app/providers/radio_station_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/api_test_setup.dart'; + +void main() { + setUpAll(initApiTestEnvironment); + setUp(setUpApiTest); + tearDown(tearDownApiTest); + + group('RadioStationProvider.toggleFavorite', () { + test('flips optimistically, posts the right body, persists on 200', + () async { + final http = CapturingClient()..install(); + + final station = RadioStation.fake(id: 's-1', favorite: false); + final provider = RadioStationProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.toggleFavorite(station); + + final req = http.requests.single; + expect(req.method, 'POST'); + expect(req.url, 'https://koel.test/api/favorites/toggle'); + expect(req.jsonBody, {'type': 'radio-station', 'id': 's-1'}); + + expect(station.favorite, isTrue); + expect(notifyCount, 1); + }); + + test('rolls back, notifies again, and rethrows on failure', () async { + final http = CapturingClient()..willReturn(status: 500)..install(); + + final station = RadioStation.fake(id: 's-2', favorite: true); + final provider = RadioStationProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await expectLater( + provider.toggleFavorite(station), + throwsA(isA()), + ); + + expect(station.favorite, isTrue); + expect(http.requests, hasLength(1)); + expect(notifyCount, 2); + }); + }); + + group('RadioStationProvider.create', () { + test( + 'POSTs the new fields, parses the response and appends to the list', + () async { + final http = CapturingClient() + ..willReturn(json: { + 'id': 'new-1', + 'name': 'Jazz FM', + 'url': 'https://stream.example.com/jazz', + 'is_public': true, + 'description': 'Smooth jazz', + 'permissions': {'edit': true, 'delete': true}, + }) + ..install(); + + final provider = RadioStationProvider(); + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + final created = await provider.create( + name: 'Jazz FM', + url: 'https://stream.example.com/jazz', + description: 'Smooth jazz', + isPublic: true, + ); + + final req = http.requests.single; + expect(req.method, 'POST'); + expect(req.url, 'https://koel.test/api/radio/stations'); + expect(req.jsonBody, { + 'name': 'Jazz FM', + 'url': 'https://stream.example.com/jazz', + 'is_public': true, + 'description': 'Smooth jazz', + }); + + expect(created.id, 'new-1'); + expect(created.canEdit, isTrue); + expect(provider.stations, [created]); + expect(notifyCount, 1); + }, + ); + + test('omits description from the body when it is null or empty', + () async { + final http = CapturingClient() + ..willReturn(json: { + 'id': 'new-2', + 'name': 'No Desc FM', + 'url': 'https://stream.example.com/x', + 'is_public': false, + }) + ..install(); + + await RadioStationProvider().create( + name: 'No Desc FM', + url: 'https://stream.example.com/x', + ); + + expect(http.requests.single.jsonBody, { + 'name': 'No Desc FM', + 'url': 'https://stream.example.com/x', + 'is_public': false, + }); + }); + }); + + group('RadioStationProvider.update', () { + test('PUTs the new fields and writes them locally', () async { + final http = CapturingClient()..install(); + + final station = RadioStation.fake(id: 's-3') + ..name = 'Old' + ..url = 'https://old/' + ..description = null + ..isPublic = false; + final provider = RadioStationProvider(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.update( + station, + name: 'New', + url: 'https://new/', + description: 'desc', + isPublic: true, + ); + + final req = http.requests.single; + expect(req.method, 'PUT'); + expect(req.url, 'https://koel.test/api/radio/stations/s-3'); + expect(req.jsonBody, { + 'name': 'New', + 'url': 'https://new/', + 'description': 'desc', + 'is_public': true, + }); + + expect(station.name, 'New'); + expect(station.url, 'https://new/'); + expect(station.description, 'desc'); + expect(station.isPublic, isTrue); + expect(notifyCount, 1); + }); + + test('serialises a null description as an empty string', () async { + final http = CapturingClient()..install(); + final station = RadioStation.fake(id: 's-4'); + + await RadioStationProvider().update( + station, + name: 'Same', + url: station.url, + description: null, + ); + + expect(http.requests.single.jsonBody!['description'], ''); + }); + }); + + group('RadioStationProvider.remove', () { + test( + 'optimistically drops the station, notifies, and fires a DELETE', + () async { + final http = CapturingClient()..install(); + + final provider = RadioStationProvider(); + // Seed the list via create() — the provider doesn't expose a + // public way to inject a station otherwise. + http.willReturn(json: { + 'id': 's-5', + 'name': 'Stationy', + 'url': 'https://stream/', + 'is_public': false, + }); + final station = await provider.create( + name: 'Stationy', + url: 'https://stream/', + ); + expect(provider.stations, [station]); + + // Reset for the actual remove call. + http.requests.clear(); + http.willReturn(); + + var notifyCount = 0; + provider.addListener(() => notifyCount++); + + await provider.remove(station); + + // Local list updated synchronously. + expect(provider.stations, isEmpty); + expect(notifyCount, 1); + + // The DELETE is fire-and-forget — drain the event queue so the + // captured client gets a chance to record it. + await Future.delayed(Duration.zero); + + expect(http.requests, hasLength(1)); + final req = http.requests.single; + expect(req.method, 'DELETE'); + expect(req.url, endsWith('/radio/stations/s-5')); + }, + ); + }); +} diff --git a/test/utils/api_request_test.dart b/test/utils/api_request_test.dart new file mode 100644 index 00000000..8bddf4ff --- /dev/null +++ b/test/utils/api_request_test.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +import 'package:app/exceptions/exceptions.dart'; +import 'package:app/utils/api_request.dart' as api; +import 'package:app/utils/preferences.dart' as preferences; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/api_test_setup.dart'; + +void main() { + setUpAll(initApiTestEnvironment); + + setUp(() { + setUpApiTest(); + // override the helper's default token so the bearer-presence test + // can assert against a known value. + preferences.apiToken = 'tok-123'; + }); + + tearDown(tearDownApiTest); + + group('request URL building', () { + test('joins apiBaseUrl with the given path', () async { + final http = CapturingClient()..install(); + + await api.get('songs/42'); + + expect(http.requests, hasLength(1)); + expect( + http.requests.single.url, + 'https://koel.test/api/songs/42', + ); + }); + }); + + group('request method dispatch', () { + test('GET sends the right method, no body', () async { + final http = CapturingClient()..install(); + + await api.get('albums'); + expect(http.requests.single.method, 'GET'); + expect(http.requests.single.rawBody, isEmpty); + }); + + test('POST sends a JSON body', () async { + final http = CapturingClient()..install(); + + await api.post('favorites/toggle', data: {'type': 'album', 'id': 7}); + + final req = http.requests.single; + expect(req.method, 'POST'); + expect(req.jsonBody, {'type': 'album', 'id': 7}); + }); + + test('PUT sends a JSON body', () async { + final http = CapturingClient()..install(); + + await api.put('artists/9', data: {'name': 'X'}); + + final req = http.requests.single; + expect(req.method, 'PUT'); + expect(req.jsonBody, {'name': 'X'}); + }); + + test('PATCH sends a JSON body', () async { + final http = CapturingClient()..install(); + + await api.patch('songs/3', data: {'title': 'Y'}); + + final req = http.requests.single; + expect(req.method, 'PATCH'); + expect(req.jsonBody, {'title': 'Y'}); + }); + + test('DELETE sends a JSON body when one is provided', () async { + final http = CapturingClient()..install(); + + await api.delete('queue/items', data: {'ids': [1, 2]}); + + final req = http.requests.single; + expect(req.method, 'DELETE'); + expect(req.jsonBody, {'ids': [1, 2]}); + }); + }); + + group('request headers', () { + test('sets JSON content-type, accept, version, and bearer', () async { + final http = CapturingClient()..install(); + + await api.get('me'); + + final headers = http.requests.single.headers; + expect(headers['content-type'], contains('application/json')); + expect(headers['accept'], contains('application/json')); + expect(headers['x-api-version'], 'v6'); + expect(headers['authorization'], 'Bearer tok-123'); + }); + + test('omits the Authorization header when no token is set', () async { + preferences.apiToken = null; + final http = CapturingClient()..install(); + + await api.get('login'); + + expect( + http.requests.single.headers.containsKey('authorization'), + isFalse, + ); + }); + }); + + group('response handling', () { + test('returns the decoded JSON on 2xx', () async { + CapturingClient() + ..willReturnRaw(body: jsonEncode({'hello': 'world'})) + ..install(); + + final result = await api.get('hello'); + expect(result, {'hello': 'world'}); + }); + + test('returns null when 2xx body is not JSON', () async { + CapturingClient()..willReturnRaw(body: 'not-json')..install(); + + final result = await api.get('hello'); + expect(result, isNull); + }); + + test('throws HttpResponseException on non-2xx', () async { + CapturingClient() + ..willReturn(status: 422, json: {'message': 'nope'}) + ..install(); + + await expectLater( + api.post('favorites/toggle', data: {}), + throwsA(isA()), + ); + }); + }); +}