From 49eaedda3d921225be957f73b2c384ab6c5ce555 Mon Sep 17 00:00:00 2001 From: Phan An Date: Sat, 2 May 2026 13:47:51 +0200 Subject: [PATCH 1/4] Make api_request's HTTP client injectable + add request-shape tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now lib/utils/api_request.dart called the top-level Http.get / post / put / patch / delete helpers from package:http directly, so nothing in the test suite could observe the URL, method, headers or body the player actually sends. The 422 bug in AlbumProvider.toggleFavorite (where we sent type: 'albums' and the koel server expects type: 'album') slipped through for exactly that reason — there was no place to write a regression test against the request shape. This commit: - Adds a module-level `_client` to api_request.dart, defaulting to a fresh http.Client(), and routes every per-method helper through it. - Exposes setHttpClientForTesting / resetHttpClientForTesting (annotated @visibleForTesting) so tests can swap in a MockClient from package:http/testing.dart. - Adds api_request_test.dart covering URL building, method dispatch for GET/POST/PUT/PATCH/DELETE, header shape (content-type, accept, X-Api-Version, optional bearer), and 2xx / non-JSON / non-2xx response paths. - Adds toggle_favorite_request_shape_test.dart that pins the singular type strings the koel server's FavoriteableType enum requires — album, artist, podcast, radio-station — for all four providers. --- lib/utils/api_request.dart | 26 ++- .../toggle_favorite_request_shape_test.dart | 107 ++++++++++ test/utils/api_request_test.dart | 183 ++++++++++++++++++ 3 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 test/providers/toggle_favorite_request_shape_test.dart create mode 100644 test/utils/api_request_test.dart 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/providers/toggle_favorite_request_shape_test.dart b/test/providers/toggle_favorite_request_shape_test.dart new file mode 100644 index 00000000..16d5ccfd --- /dev/null +++ b/test/providers/toggle_favorite_request_shape_test.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +import 'package:app/models/album.dart'; +import 'package:app/models/artist.dart'; +import 'package:app/models/podcast.dart'; +import 'package:app/models/radio_station.dart'; +import 'package:app/providers/album_provider.dart'; +import 'package:app/providers/artist_provider.dart'; +import 'package:app/providers/podcast_provider.dart'; +import 'package:app/providers/radio_station_provider.dart'; +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'; + +/// Captures every outgoing request's URL and decoded JSON body so test +/// cases can assert exact request shape — locking in the singular +/// `type` strings the koel server's FavoriteableType enum requires. +class _Recorder { + final List urls = []; + final List> bodies = []; + + late final MockClient client = MockClient((request) async { + urls.add(request.url.toString()); + bodies.add(json.decode(request.body) as Map); + return Http.Response('{}', 200); + }); +} + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => '.'); + await GetStorage.init('Preferences'); + }); + + setUp(() { + preferences.host = 'https://koel.test'; + preferences.apiToken = 'tok'; + }); + + tearDown(() { + preferences.host = null; + preferences.apiToken = null; + api.resetHttpClientForTesting(); + }); + + test('AlbumProvider.toggleFavorite POSTs type:album (singular)', () async { + final recorder = _Recorder(); + api.setHttpClientForTesting(recorder.client); + + final album = Album.fake(id: 7); + await AlbumProvider().toggleFavorite(album); + + expect(recorder.urls, ['https://koel.test/api/favorites/toggle']); + expect(recorder.bodies, [ + {'type': 'album', 'id': 7}, + ]); + }); + + test('ArtistProvider.toggleFavorite POSTs type:artist (singular)', + () async { + final recorder = _Recorder(); + api.setHttpClientForTesting(recorder.client); + + final artist = Artist.fake(id: 9); + await ArtistProvider().toggleFavorite(artist); + + expect(recorder.bodies, [ + {'type': 'artist', 'id': 9}, + ]); + }); + + test( + 'RadioStationProvider.toggleFavorite POSTs type:radio-station ' + '(singular, hyphenated)', + () async { + final recorder = _Recorder(); + api.setHttpClientForTesting(recorder.client); + + final station = RadioStation.fake(id: 's1'); + await RadioStationProvider().toggleFavorite(station); + + expect(recorder.bodies, [ + {'type': 'radio-station', 'id': 's1'}, + ]); + }, + ); + + test('PodcastProvider.toggleFavorite POSTs type:podcast (singular)', + () async { + final recorder = _Recorder(); + api.setHttpClientForTesting(recorder.client); + + final podcast = Podcast.fake(id: 'p1'); + await PodcastProvider().toggleFavorite(podcast); + + expect(recorder.bodies, [ + {'type': 'podcast', 'id': 'p1'}, + ]); + }); +} diff --git a/test/utils/api_request_test.dart b/test/utils/api_request_test.dart new file mode 100644 index 00000000..bab80f77 --- /dev/null +++ b/test/utils/api_request_test.dart @@ -0,0 +1,183 @@ +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/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'; + +class _CapturingClient { + final MockClient client; + final List captured; + _CapturingClient(this.client, this.captured); +} + +/// Builds a [MockClient] that records every request it receives and +/// replies with [responseStatus] / [responseBody]. +_CapturingClient _captureClient({ + int responseStatus = 200, + String responseBody = '{}', +}) { + final captured = []; + final client = MockClient((Http.Request request) async { + captured.add(request); + return Http.Response(responseBody, responseStatus); + }); + return _CapturingClient(client, captured); +} + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + // GetStorage.init() pulls the docs dir via path_provider, which has + // no plugin implementation in widget tests. Stub the channel so + // GetStorage falls back to a writable location. + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => '.'); + await GetStorage.init('Preferences'); + }); + + setUp(() { + preferences.host = 'https://koel.test'; + preferences.apiToken = 'tok-123'; + }); + + tearDown(() { + preferences.host = null; + preferences.apiToken = null; + api.resetHttpClientForTesting(); + }); + + group('request URL building', () { + test('joins apiBaseUrl with the given path', () async { + final mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.get('songs/42'); + + expect(mock.captured, hasLength(1)); + expect( + mock.captured.single.url.toString(), + 'https://koel.test/api/songs/42', + ); + }); + }); + + group('request method dispatch', () { + test('GET sends the right method, no body', () async { + final mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.get('albums'); + expect(mock.captured.single.method, 'GET'); + expect(mock.captured.single.body, isEmpty); + }); + + test('POST sends a JSON body', () async { + final mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.post('favorites/toggle', data: {'type': 'album', 'id': 7}); + + final req = mock.captured.single; + expect(req.method, 'POST'); + expect(json.decode(req.body), {'type': 'album', 'id': 7}); + }); + + test('PUT sends a JSON body', () async { + final mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.put('artists/9', data: {'name': 'X'}); + + final req = mock.captured.single; + expect(req.method, 'PUT'); + expect(json.decode(req.body), {'name': 'X'}); + }); + + test('PATCH sends a JSON body', () async { + final mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.patch('songs/3', data: {'title': 'Y'}); + + final req = mock.captured.single; + expect(req.method, 'PATCH'); + expect(json.decode(req.body), {'title': 'Y'}); + }); + + test('DELETE sends a JSON body when one is provided', () async { + final mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.delete('queue/items', data: {'ids': [1, 2]}); + + final req = mock.captured.single; + expect(req.method, 'DELETE'); + expect(json.decode(req.body), {'ids': [1, 2]}); + }); + }); + + group('request headers', () { + test('sets JSON content-type, accept, version, and bearer', () async { + final mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.get('me'); + + final headers = mock.captured.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 mock = _captureClient(); + api.setHttpClientForTesting(mock.client); + + await api.get('login'); + + expect( + mock.captured.single.headers.containsKey('authorization'), + isFalse, + ); + }); + }); + + group('response handling', () { + test('returns the decoded JSON on 2xx', () async { + final mock = _captureClient(responseBody: '{"hello":"world"}'); + api.setHttpClientForTesting(mock.client); + + final result = await api.get('hello'); + expect(result, {'hello': 'world'}); + }); + + test('returns null when 2xx body is not JSON', () async { + final mock = _captureClient(responseBody: 'not-json'); + api.setHttpClientForTesting(mock.client); + + final result = await api.get('hello'); + expect(result, isNull); + }); + + test('throws HttpResponseException on non-2xx', () async { + final mock = _captureClient( + responseStatus: 422, + responseBody: '{"message":"nope"}', + ); + api.setHttpClientForTesting(mock.client); + + expect( + () => api.post('favorites/toggle', data: {}), + throwsA(isA()), + ); + }); + }); +} From 95295df179096bec77296bbd37a16bb0c5a01086 Mon Sep 17 00:00:00 2001 From: Phan An Date: Sat, 2 May 2026 13:51:31 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Drop=20request-shape=20pin=20tests=20?= =?UTF-8?q?=E2=80=94=20they're=20just=20noise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asserting that toggleFavorite POSTs type:album doesn't catch anything the source doesn't already say plainly. If someone typos the source they'll typo the test the same way — which is exactly how the original 422 bug would have been written and would have passed. Keep the actually-useful piece: the injectable http.Client and the api_request_test.dart that exercises real request/response behaviour. --- .../toggle_favorite_request_shape_test.dart | 107 ------------------ 1 file changed, 107 deletions(-) delete mode 100644 test/providers/toggle_favorite_request_shape_test.dart diff --git a/test/providers/toggle_favorite_request_shape_test.dart b/test/providers/toggle_favorite_request_shape_test.dart deleted file mode 100644 index 16d5ccfd..00000000 --- a/test/providers/toggle_favorite_request_shape_test.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:convert'; - -import 'package:app/models/album.dart'; -import 'package:app/models/artist.dart'; -import 'package:app/models/podcast.dart'; -import 'package:app/models/radio_station.dart'; -import 'package:app/providers/album_provider.dart'; -import 'package:app/providers/artist_provider.dart'; -import 'package:app/providers/podcast_provider.dart'; -import 'package:app/providers/radio_station_provider.dart'; -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'; - -/// Captures every outgoing request's URL and decoded JSON body so test -/// cases can assert exact request shape — locking in the singular -/// `type` strings the koel server's FavoriteableType enum requires. -class _Recorder { - final List urls = []; - final List> bodies = []; - - late final MockClient client = MockClient((request) async { - urls.add(request.url.toString()); - bodies.add(json.decode(request.body) as Map); - return Http.Response('{}', 200); - }); -} - -void main() { - setUpAll(() async { - TestWidgetsFlutterBinding.ensureInitialized(); - const channel = MethodChannel('plugins.flutter.io/path_provider'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => '.'); - await GetStorage.init('Preferences'); - }); - - setUp(() { - preferences.host = 'https://koel.test'; - preferences.apiToken = 'tok'; - }); - - tearDown(() { - preferences.host = null; - preferences.apiToken = null; - api.resetHttpClientForTesting(); - }); - - test('AlbumProvider.toggleFavorite POSTs type:album (singular)', () async { - final recorder = _Recorder(); - api.setHttpClientForTesting(recorder.client); - - final album = Album.fake(id: 7); - await AlbumProvider().toggleFavorite(album); - - expect(recorder.urls, ['https://koel.test/api/favorites/toggle']); - expect(recorder.bodies, [ - {'type': 'album', 'id': 7}, - ]); - }); - - test('ArtistProvider.toggleFavorite POSTs type:artist (singular)', - () async { - final recorder = _Recorder(); - api.setHttpClientForTesting(recorder.client); - - final artist = Artist.fake(id: 9); - await ArtistProvider().toggleFavorite(artist); - - expect(recorder.bodies, [ - {'type': 'artist', 'id': 9}, - ]); - }); - - test( - 'RadioStationProvider.toggleFavorite POSTs type:radio-station ' - '(singular, hyphenated)', - () async { - final recorder = _Recorder(); - api.setHttpClientForTesting(recorder.client); - - final station = RadioStation.fake(id: 's1'); - await RadioStationProvider().toggleFavorite(station); - - expect(recorder.bodies, [ - {'type': 'radio-station', 'id': 's1'}, - ]); - }, - ); - - test('PodcastProvider.toggleFavorite POSTs type:podcast (singular)', - () async { - final recorder = _Recorder(); - api.setHttpClientForTesting(recorder.client); - - final podcast = Podcast.fake(id: 'p1'); - await PodcastProvider().toggleFavorite(podcast); - - expect(recorder.bodies, [ - {'type': 'podcast', 'id': 'p1'}, - ]); - }); -} From 45455d4fe38886b93a7878cfa88cf92f163f62e2 Mon Sep 17 00:00:00 2001 From: Phan An Date: Sat, 2 May 2026 14:03:15 +0200 Subject: [PATCH 3/4] Test provider behaviour against a real round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour tests for AlbumProvider, ArtistProvider, RadioStationProvider, PodcastProvider, and PlayableProvider.fetchForPodcast — driven by a shared MockClient harness so each test exercises the actual request → response → state-mutation → notifyListeners pipeline. For each operation the suite locks down: - the URL, method, and JSON body the provider sends; - the model fields the response merges into; - the listener notification count; - the rollback + rethrow shape on a non-2xx response, where the provider does an optimistic mutation (toggleFavorite, unsubscribePodcast). The CapturingClient helper in test/helpers/api_test_setup.dart wires in the path_provider mock + GetStorage init so every provider test gets a clean preferences slate (per-isolate temp dir to avoid the './Preferences.gs' file lock contention when test files run in parallel). Net 25 new tests; 375 pass total. --- test/helpers/api_test_setup.dart | 108 +++++++++ test/providers/album_provider_test.dart | 103 ++++++++ test/providers/artist_provider_test.dart | 78 +++++++ test/providers/playable_provider_test.dart | 125 ++++++++++ test/providers/podcast_provider_test.dart | 181 ++++++++++++++ .../radio_station_provider_test.dart | 221 ++++++++++++++++++ test/utils/api_request_test.dart | 113 +++------ 7 files changed, 851 insertions(+), 78 deletions(-) create mode 100644 test/helpers/api_test_setup.dart create mode 100644 test/providers/album_provider_test.dart create mode 100644 test/providers/artist_provider_test.dart create mode 100644 test/providers/playable_provider_test.dart create mode 100644 test/providers/podcast_provider_test.dart create mode 100644 test/providers/radio_station_provider_test.dart 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 index bab80f77..31766f51 100644 --- a/test/utils/api_request_test.dart +++ b/test/utils/api_request_test.dart @@ -3,65 +3,31 @@ 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/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'; - -class _CapturingClient { - final MockClient client; - final List captured; - _CapturingClient(this.client, this.captured); -} -/// Builds a [MockClient] that records every request it receives and -/// replies with [responseStatus] / [responseBody]. -_CapturingClient _captureClient({ - int responseStatus = 200, - String responseBody = '{}', -}) { - final captured = []; - final client = MockClient((Http.Request request) async { - captured.add(request); - return Http.Response(responseBody, responseStatus); - }); - return _CapturingClient(client, captured); -} +import '../helpers/api_test_setup.dart'; void main() { - setUpAll(() async { - TestWidgetsFlutterBinding.ensureInitialized(); - // GetStorage.init() pulls the docs dir via path_provider, which has - // no plugin implementation in widget tests. Stub the channel so - // GetStorage falls back to a writable location. - const channel = MethodChannel('plugins.flutter.io/path_provider'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async => '.'); - await GetStorage.init('Preferences'); - }); + setUpAll(initApiTestEnvironment); setUp(() { - preferences.host = 'https://koel.test'; + setUpApiTest(); + // override the helper's default token so the bearer-presence test + // can assert against a known value. preferences.apiToken = 'tok-123'; }); - tearDown(() { - preferences.host = null; - preferences.apiToken = null; - api.resetHttpClientForTesting(); - }); + tearDown(tearDownApiTest); group('request URL building', () { test('joins apiBaseUrl with the given path', () async { - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.get('songs/42'); - expect(mock.captured, hasLength(1)); + expect(http.requests, hasLength(1)); expect( - mock.captured.single.url.toString(), + http.requests.single.url, 'https://koel.test/api/songs/42', ); }); @@ -69,67 +35,61 @@ void main() { group('request method dispatch', () { test('GET sends the right method, no body', () async { - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.get('albums'); - expect(mock.captured.single.method, 'GET'); - expect(mock.captured.single.body, isEmpty); + expect(http.requests.single.method, 'GET'); + expect(http.requests.single.rawBody, isEmpty); }); test('POST sends a JSON body', () async { - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.post('favorites/toggle', data: {'type': 'album', 'id': 7}); - final req = mock.captured.single; + final req = http.requests.single; expect(req.method, 'POST'); - expect(json.decode(req.body), {'type': 'album', 'id': 7}); + expect(req.jsonBody, {'type': 'album', 'id': 7}); }); test('PUT sends a JSON body', () async { - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.put('artists/9', data: {'name': 'X'}); - final req = mock.captured.single; + final req = http.requests.single; expect(req.method, 'PUT'); - expect(json.decode(req.body), {'name': 'X'}); + expect(req.jsonBody, {'name': 'X'}); }); test('PATCH sends a JSON body', () async { - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.patch('songs/3', data: {'title': 'Y'}); - final req = mock.captured.single; + final req = http.requests.single; expect(req.method, 'PATCH'); - expect(json.decode(req.body), {'title': 'Y'}); + expect(req.jsonBody, {'title': 'Y'}); }); test('DELETE sends a JSON body when one is provided', () async { - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.delete('queue/items', data: {'ids': [1, 2]}); - final req = mock.captured.single; + final req = http.requests.single; expect(req.method, 'DELETE'); - expect(json.decode(req.body), {'ids': [1, 2]}); + expect(req.jsonBody, {'ids': [1, 2]}); }); }); group('request headers', () { test('sets JSON content-type, accept, version, and bearer', () async { - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.get('me'); - final headers = mock.captured.single.headers; + 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'); @@ -138,13 +98,12 @@ void main() { test('omits the Authorization header when no token is set', () async { preferences.apiToken = null; - final mock = _captureClient(); - api.setHttpClientForTesting(mock.client); + final http = CapturingClient()..install(); await api.get('login'); expect( - mock.captured.single.headers.containsKey('authorization'), + http.requests.single.headers.containsKey('authorization'), isFalse, ); }); @@ -152,27 +111,25 @@ void main() { group('response handling', () { test('returns the decoded JSON on 2xx', () async { - final mock = _captureClient(responseBody: '{"hello":"world"}'); - api.setHttpClientForTesting(mock.client); + 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 { - final mock = _captureClient(responseBody: 'not-json'); - api.setHttpClientForTesting(mock.client); + CapturingClient()..willReturnRaw(body: 'not-json')..install(); final result = await api.get('hello'); expect(result, isNull); }); test('throws HttpResponseException on non-2xx', () async { - final mock = _captureClient( - responseStatus: 422, - responseBody: '{"message":"nope"}', - ); - api.setHttpClientForTesting(mock.client); + CapturingClient() + ..willReturn(status: 422, json: {'message': 'nope'}) + ..install(); expect( () => api.post('favorites/toggle', data: {}), From 050028b89204ee49955463e3184dff8270d441d7 Mon Sep 17 00:00:00 2001 From: Phan An Date: Sat, 2 May 2026 14:08:13 +0200 Subject: [PATCH 4/4] Address review: await the non-2xx throwsA assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expect(closure, throwsA(...)) on a Future-returning closure resolves asynchronously. Without awaiting, the test body returns before the assertion completes — so a regression where the throw stops happening would pass silently. Switch to await expectLater so the test actually waits for the throw. --- test/utils/api_request_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/utils/api_request_test.dart b/test/utils/api_request_test.dart index 31766f51..8bddf4ff 100644 --- a/test/utils/api_request_test.dart +++ b/test/utils/api_request_test.dart @@ -131,8 +131,8 @@ void main() { ..willReturn(status: 422, json: {'message': 'nope'}) ..install(); - expect( - () => api.post('favorites/toggle', data: {}), + await expectLater( + api.post('favorites/toggle', data: {}), throwsA(isA()), ); });