feat(trakt): catalog API and shared catalog core (watchlist, discover, recommendations)
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/media/catalog_item_ref.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||
import 'package:plezy/services/catalog/trakt_catalog_source.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
|
||||
TrackerSession _session() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return TrackerSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: now + 86400,
|
||||
scope: 'public',
|
||||
createdAt: now - 3600,
|
||||
username: 'alice',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _watchlistBody() => {
|
||||
'entries': [
|
||||
{
|
||||
'rank': 1,
|
||||
'type': 'movie',
|
||||
'movie': {
|
||||
'title': 'The Matrix',
|
||||
'year': 1999,
|
||||
'ids': {'trakt': 1, 'imdb': 'tt0133093', 'tmdb': 603},
|
||||
},
|
||||
},
|
||||
{
|
||||
'rank': 2,
|
||||
'type': 'show',
|
||||
'show': {
|
||||
'title': 'Severance',
|
||||
'year': 2022,
|
||||
'ids': {'trakt': 2, 'imdb': 'tt11280740', 'tmdb': 95396, 'tvdb': 371980},
|
||||
'status': 'returning series',
|
||||
'network': 'Apple TV+',
|
||||
'aired_episodes': 19,
|
||||
'votes': 7294,
|
||||
'rating': 8.5,
|
||||
},
|
||||
},
|
||||
// Episode entries are not Explore rows and must be skipped.
|
||||
{
|
||||
'rank': 3,
|
||||
'type': 'episode',
|
||||
'episode': {
|
||||
'title': 'Pilot',
|
||||
'ids': {'trakt': 99},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('TraktCatalogSource', () {
|
||||
late List<http.Request> requests;
|
||||
late List<http.Response Function(http.Request)> handlers;
|
||||
late TraktClient client;
|
||||
late TraktCatalogSource source;
|
||||
|
||||
setUp(() {
|
||||
requests = [];
|
||||
handlers = [];
|
||||
client = TraktClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('should not invalidate'),
|
||||
httpClient: MockClient((request) async {
|
||||
requests.add(request);
|
||||
if (handlers.isNotEmpty) return handlers.removeAt(0)(request);
|
||||
return http.Response(json.encode(_watchlistBody()['entries']), 200);
|
||||
}),
|
||||
);
|
||||
source = TraktCatalogSource(client);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
source.dispose();
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('fetchRow(watchlist) maps mixed entries and skips non-movie/show types', () async {
|
||||
final page = await source.fetchRow(CatalogRowId.watchlist);
|
||||
|
||||
expect(requests.single.url.path, '/sync/watchlist');
|
||||
expect(page.items, hasLength(2));
|
||||
expect(page.items[0].kind, MediaKind.movie);
|
||||
expect(page.items[0].identityKey, 'movie/imdb:tt0133093');
|
||||
expect(page.items[1].kind, MediaKind.show);
|
||||
|
||||
// extended=full metadata flows through to the item.
|
||||
final show = page.items[1];
|
||||
expect(show.airStatus, CatalogAirStatus.airing);
|
||||
expect(show.network, 'Apple TV+');
|
||||
expect(show.episodeCount, 19);
|
||||
expect(show.votes, 7294);
|
||||
expect(show.rating, 8.5);
|
||||
expect(page.items[0].airStatus, isNull);
|
||||
|
||||
final rendered = page.items[0].toMediaItem();
|
||||
expect(rendered.serverId, isNull);
|
||||
expect(rendered.title, 'The Matrix');
|
||||
expect(rendered.isCatalogItem, isTrue);
|
||||
final roundTripped = rendered.catalogItem;
|
||||
expect(roundTripped?.title, 'The Matrix');
|
||||
expect(roundTripped?.ids.imdb, 'tt0133093');
|
||||
expect(roundTripped?.kind, MediaKind.movie);
|
||||
});
|
||||
|
||||
test('membership matches on any shared id form after snapshot load', () async {
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull);
|
||||
|
||||
var notified = 0;
|
||||
source.watchlistChanges.addListener(() => notified++);
|
||||
await source.ensureWatchlistLoaded();
|
||||
|
||||
expect(notified, 1);
|
||||
// Query by tmdb only — snapshot entry also carries imdb/trakt.
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isTrue);
|
||||
// Same tmdb id under the other kind must not match.
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 603)), isFalse);
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt11280740')), isTrue);
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt9999999')), isFalse);
|
||||
});
|
||||
|
||||
test('addToWatchlist is optimistic and posts a typed ids body', () async {
|
||||
await source.ensureWatchlistLoaded();
|
||||
requests.clear();
|
||||
|
||||
handlers.add((request) => http.Response('{"added":{"shows":1}}', 201));
|
||||
await source.addToWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt0903747', tmdb: 1396));
|
||||
|
||||
final request = requests.single;
|
||||
expect(request.url.path, '/sync/watchlist');
|
||||
expect(json.decode(request.body), {
|
||||
'shows': [
|
||||
{
|
||||
'ids': {'imdb': 'tt0903747', 'tmdb': 1396},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tmdb: 1396)), isTrue);
|
||||
});
|
||||
|
||||
test('fetchCast maps people to cast members with https-prefixed headshots', () async {
|
||||
handlers.add(
|
||||
(request) => http.Response(
|
||||
json.encode({
|
||||
'cast': [
|
||||
{
|
||||
'characters': ['Walter White'],
|
||||
'person': {
|
||||
'name': 'Bryan Cranston',
|
||||
'images': {
|
||||
'headshot': ['media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
'characters': <String>[],
|
||||
'person': {'name': 'Aaron Paul'},
|
||||
},
|
||||
{'characters': <String>[]}, // no person — skipped
|
||||
],
|
||||
'crew': <String, dynamic>{},
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
final cast = await source.fetchCast(
|
||||
const CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Breaking Bad',
|
||||
ids: CatalogItemIds(trakt: 1388, slug: 'breaking-bad'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(requests.single.url.path, '/shows/1388/people');
|
||||
expect(cast, hasLength(2));
|
||||
expect(cast[0].name, 'Bryan Cranston');
|
||||
expect(cast[0].secondary, 'Walter White');
|
||||
expect(cast[0].imageUrl, 'https://media.trakt.tv/images/people/headshots/medium/25eb34a2d5.jpg.webp');
|
||||
expect(cast[1].imageUrl, isNull);
|
||||
expect(cast[1].secondary, isNull);
|
||||
});
|
||||
|
||||
test('air status normalization covers the Trakt vocabulary', () {
|
||||
expect(TraktCatalogSource.airStatusFor('returning series'), CatalogAirStatus.airing);
|
||||
expect(TraktCatalogSource.airStatusFor('continuing'), CatalogAirStatus.airing);
|
||||
expect(TraktCatalogSource.airStatusFor('ended'), CatalogAirStatus.ended);
|
||||
expect(TraktCatalogSource.airStatusFor('canceled'), CatalogAirStatus.canceled);
|
||||
expect(TraktCatalogSource.airStatusFor('in production'), CatalogAirStatus.upcoming);
|
||||
expect(TraktCatalogSource.airStatusFor('post production'), CatalogAirStatus.upcoming);
|
||||
expect(TraktCatalogSource.airStatusFor('released'), isNull);
|
||||
expect(TraktCatalogSource.airStatusFor(null), isNull);
|
||||
});
|
||||
|
||||
test('remove with a subset of id forms drops the sibling keys too', () async {
|
||||
await source.ensureWatchlistLoaded();
|
||||
handlers.add((request) => http.Response('{"deleted":{"movies":1}}', 200));
|
||||
|
||||
// Media-detail path: ids come from server externals — no trakt/slug.
|
||||
await source.removeFromWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt0133093', tmdb: 603));
|
||||
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isFalse);
|
||||
// The sibling trakt id the mutation didn't carry must be gone as well.
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(trakt: 1)), isFalse);
|
||||
// The other entry is untouched.
|
||||
expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(imdb: 'tt11280740')), isTrue);
|
||||
});
|
||||
|
||||
test('snapshot load failure is swallowed; membership stays unknown and retries', () async {
|
||||
handlers.add((request) => http.Response('oops', 500));
|
||||
|
||||
await source.ensureWatchlistLoaded(); // must not throw
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull);
|
||||
|
||||
// Next call retries (default handler serves the snapshot).
|
||||
await source.ensureWatchlistLoaded();
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isTrue);
|
||||
});
|
||||
|
||||
test('failed mutation reverts the optimistic snapshot flip', () async {
|
||||
await source.ensureWatchlistLoaded();
|
||||
|
||||
var notified = 0;
|
||||
source.watchlistChanges.addListener(() => notified++);
|
||||
handlers.add((request) => http.Response('oops', 500));
|
||||
|
||||
await expectLater(
|
||||
source.removeFromWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt0133093')),
|
||||
throwsA(anything),
|
||||
);
|
||||
|
||||
expect(notified, 2); // optimistic flip + revert
|
||||
expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(imdb: 'tt0133093')), isTrue);
|
||||
});
|
||||
|
||||
test('search hits /search/movie,show and maps the typed wrappers', () async {
|
||||
handlers.add((request) {
|
||||
expect(request.url.path, '/search/movie,show');
|
||||
expect(request.url.queryParameters['query'], 'blade runner');
|
||||
return http.Response(
|
||||
json.encode([
|
||||
{
|
||||
'type': 'movie',
|
||||
'score': 100.0,
|
||||
'movie': {
|
||||
'title': 'Blade Runner',
|
||||
'year': 1982,
|
||||
'ids': {'trakt': 3, 'imdb': 'tt0083658'},
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'show',
|
||||
'score': 50.0,
|
||||
'show': {
|
||||
'title': 'Blade Runner: Black Lotus',
|
||||
'year': 2021,
|
||||
'ids': {'trakt': 4, 'tmdb': 93830},
|
||||
},
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final items = await source.search('blade runner');
|
||||
expect(items, hasLength(2));
|
||||
expect(items[0].kind, MediaKind.movie);
|
||||
expect(items[0].title, 'Blade Runner');
|
||||
expect(items[1].kind, MediaKind.show);
|
||||
});
|
||||
|
||||
test('search with a blank query returns empty without a request', () async {
|
||||
expect(await source.search(' '), isEmpty);
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('fetchRelated hits /related and keeps the item kind', () async {
|
||||
handlers.add((request) {
|
||||
expect(request.url.path, '/shows/2/related');
|
||||
return http.Response(
|
||||
json.encode([
|
||||
{
|
||||
'title': 'Dark',
|
||||
'year': 2017,
|
||||
'ids': {'trakt': 5, 'tmdb': 70523},
|
||||
},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final item = CatalogItem(
|
||||
source: CatalogSourceId.trakt,
|
||||
kind: MediaKind.show,
|
||||
title: 'Severance',
|
||||
ids: const CatalogItemIds(trakt: 2),
|
||||
);
|
||||
final related = await source.fetchRelated(item);
|
||||
expect(related.single.title, 'Dark');
|
||||
expect(related.single.kind, MediaKind.show);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/models/trakt/trakt_catalog_entry.dart';
|
||||
import 'package:plezy/models/trakt/trakt_catalog_media.dart';
|
||||
import 'package:plezy/models/trakt/trakt_images.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trakt/trakt_client.dart';
|
||||
import 'package:plezy/services/trakt/trakt_constants.dart';
|
||||
|
||||
int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
TrackerSession _session() {
|
||||
final now = _now();
|
||||
return TrackerSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: now + 86400,
|
||||
scope: 'public',
|
||||
createdAt: now - 3600,
|
||||
username: 'alice',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _movieJson({int trakt = 1, String? posterUrl = 'walter-r2.trakt.tv/images/movies/p.webp'}) {
|
||||
return {
|
||||
'title': 'The Matrix',
|
||||
'year': 1999,
|
||||
'ids': {'trakt': trakt, 'slug': 'the-matrix-1999', 'imdb': 'tt0133093', 'tmdb': 603},
|
||||
'overview': 'A hacker learns the truth.',
|
||||
'runtime': 136,
|
||||
'rating': 8.7,
|
||||
'votes': 42000,
|
||||
'genres': ['action', 'sci-fi'],
|
||||
'certification': 'R',
|
||||
'trailer': 'https://youtube.com/watch?v=m8e-FF8MsqU',
|
||||
'images': {
|
||||
'poster': [?posterUrl],
|
||||
'fanart': ['walter-r2.trakt.tv/images/movies/f.webp'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _showJson() {
|
||||
return {
|
||||
'title': 'Severance',
|
||||
'year': 2022,
|
||||
'ids': {'trakt': 2, 'slug': 'severance', 'imdb': 'tt11280740', 'tmdb': 95396, 'tvdb': 371980},
|
||||
'overview': 'Work-life balance, surgically.',
|
||||
'runtime': 50,
|
||||
'rating': 8.9,
|
||||
'images': <String, dynamic>{},
|
||||
};
|
||||
}
|
||||
|
||||
TraktClient _client(Future<http.Response> Function(http.Request) handler, {List<http.Request>? requests}) {
|
||||
return TraktClient(
|
||||
_session(),
|
||||
onSessionInvalidated: () => fail('should not invalidate'),
|
||||
httpClient: MockClient((request) {
|
||||
requests?.add(request);
|
||||
return handler(request);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('TraktImages', () {
|
||||
test('prefixes protocol-less CDN URLs with https', () {
|
||||
final images = TraktImages.fromJson({
|
||||
'poster': ['walter-r2.trakt.tv/images/movies/p.webp'],
|
||||
});
|
||||
expect(images.primaryPoster, 'https://walter-r2.trakt.tv/images/movies/p.webp');
|
||||
});
|
||||
|
||||
test('keeps absolute URLs and falls back fanart -> thumb for backdrop', () {
|
||||
final images = TraktImages.fromJson({
|
||||
'poster': ['https://example.com/p.webp'],
|
||||
'thumb': ['walter-r2.trakt.tv/t.webp'],
|
||||
});
|
||||
expect(images.primaryPoster, 'https://example.com/p.webp');
|
||||
expect(images.primaryBackdrop, 'https://walter-r2.trakt.tv/t.webp');
|
||||
});
|
||||
|
||||
test('returns null for missing or empty image arrays', () {
|
||||
final images = TraktImages.fromJson({'poster': <String>[]});
|
||||
expect(images.primaryPoster, isNull);
|
||||
expect(images.primaryBackdrop, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('TraktClient catalog', () {
|
||||
test('getWatchlist parses wrapped entries and sends extended=full,images', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async {
|
||||
return http.Response(
|
||||
json.encode([
|
||||
{'rank': 1, 'listed_at': '2026-01-01T00:00:00.000Z', 'type': 'movie', 'movie': _movieJson()},
|
||||
{'rank': 2, 'listed_at': '2026-01-02T00:00:00.000Z', 'type': 'show', 'show': _showJson()},
|
||||
]),
|
||||
200,
|
||||
headers: {'x-pagination-page': '1', 'x-pagination-page-count': '3', 'x-pagination-item-count': '250'},
|
||||
);
|
||||
});
|
||||
|
||||
final page = await client.getWatchlist(type: TraktCatalogType.movies);
|
||||
|
||||
final request = requests.single;
|
||||
expect(request.url.path, '/sync/watchlist/movies/added');
|
||||
expect(request.url.queryParameters['extended'], 'full,images');
|
||||
expect(request.headers['Authorization'], 'Bearer access');
|
||||
|
||||
expect(page.items, hasLength(2));
|
||||
expect(page.items[0].isShow, isFalse);
|
||||
expect(page.items[0].media?.title, 'The Matrix');
|
||||
expect(page.items[0].media?.ids.imdb, 'tt0133093');
|
||||
expect(page.items[0].media?.images?.primaryPoster, 'https://walter-r2.trakt.tv/images/movies/p.webp');
|
||||
expect(page.items[1].isShow, isTrue);
|
||||
expect(page.items[1].media?.ids.tvdb, 371980);
|
||||
expect(page.items[1].media?.images?.primaryPoster, isNull);
|
||||
expect(page.page, 1);
|
||||
expect(page.pageCount, 3);
|
||||
expect(page.itemCount, 250);
|
||||
expect(page.hasMore, isTrue);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('getWatchlist defaults to a single page when pagination headers are absent', () async {
|
||||
final client = _client((request) async => http.Response(json.encode([]), 200));
|
||||
|
||||
final page = await client.getWatchlist(type: TraktCatalogType.shows);
|
||||
|
||||
expect(page.items, isEmpty);
|
||||
expect(page.page, 1);
|
||||
expect(page.pageCount, 1);
|
||||
expect(page.hasMore, isFalse);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('getTrending parses watcher-wrapped entries', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async {
|
||||
return http.Response(
|
||||
json.encode([
|
||||
{'watchers': 120, 'movie': _movieJson()},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final page = await client.getTrending(TraktCatalogType.movies, page: 2, limit: 10);
|
||||
|
||||
expect(requests.single.url.path, '/movies/trending');
|
||||
expect(requests.single.url.queryParameters['page'], '2');
|
||||
expect(requests.single.url.queryParameters['limit'], '10');
|
||||
expect(page.items.single.watchers, 120);
|
||||
expect(page.items.single.media?.title, 'The Matrix');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('getPopular parses bare media objects', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async {
|
||||
return http.Response(json.encode([_showJson()]), 200);
|
||||
});
|
||||
|
||||
final page = await client.getPopular(TraktCatalogType.shows);
|
||||
|
||||
expect(requests.single.url.path, '/shows/popular');
|
||||
expect(page.items.single, isA<TraktCatalogMedia>());
|
||||
expect(page.items.single.title, 'Severance');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('getRecommended passes ignore flags and parses bare media', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async {
|
||||
return http.Response(json.encode([_movieJson()]), 200);
|
||||
});
|
||||
|
||||
final items = await client.getRecommended(TraktCatalogType.movies, limit: 15);
|
||||
|
||||
final request = requests.single;
|
||||
expect(request.url.path, '/recommendations/movies');
|
||||
expect(request.url.queryParameters['limit'], '15');
|
||||
expect(request.url.queryParameters['ignore_collected'], 'false');
|
||||
expect(request.url.queryParameters['ignore_watchlisted'], 'true');
|
||||
expect(items.single.title, 'The Matrix');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('addToWatchlist accepts 201 and posts the ids body untouched', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async => http.Response('{"added":{"movies":1}}', 201));
|
||||
|
||||
final body = {
|
||||
'movies': [
|
||||
{
|
||||
'ids': {'imdb': 'tt0133093'},
|
||||
},
|
||||
],
|
||||
};
|
||||
await client.addToWatchlist(body);
|
||||
|
||||
expect(requests.single.url.path, '/sync/watchlist');
|
||||
expect(json.decode(requests.single.body), body);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('removeFromWatchlist posts to the remove endpoint', () async {
|
||||
final requests = <http.Request>[];
|
||||
final client = _client(requests: requests, (request) async => http.Response('{"deleted":{"shows":1}}', 200));
|
||||
|
||||
await client.removeFromWatchlist(const {'shows': []});
|
||||
|
||||
expect(requests.single.url.path, '/sync/watchlist/remove');
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('malformed entries are skipped instead of throwing', () async {
|
||||
final client = _client((request) async {
|
||||
return http.Response(json.encode(['not-a-map', 42]), 200);
|
||||
});
|
||||
|
||||
final page = await client.getTrending(TraktCatalogType.shows);
|
||||
expect(page.items, isEmpty);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('entry without movie or show yields null media', () {
|
||||
final entry = TraktCatalogEntry.fromJson(const {'rank': 1, 'type': 'movie'});
|
||||
expect(entry.media, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user