Schedule backend: AniList client, AnimeFlow client, schedule matching
lib/src/schedule/ for the 1.0 Schedule tab (UI and docs come with the schedule round): - AniListClient: airingSchedules in a window, 50 per page, adult/music dropped; memory 10 min + disk 1 h cache, one Retry-After wait on 429, stale cache on failure; real User-Agent (AniList 403s some defaults). - AnimeFlowClient: the full app API (me, schedule, series, follow, search, requests incl. approve/reject, users/scopes) with the user's Jellyfin token, only for a Jellyfin on AnimeFlow's parent domain; ETag/304, typed error codes, backoff on nginx's HTML 429, /me cached at most 60 s so scope changes show. - matchSchedule + ScheduleRepository: AniList airings with AnimeFlow status by (episode anilist_id, episode), then absolute number, then air day; degrades to AniList-only when AnimeFlow fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c76fee70dc
commit
caa0765890
7 files changed
+2072
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/schedule/anilist.dart';
|
||||
|
||||
import 'schedule_fake_server.dart';
|
||||
|
||||
Map<String, Object?> airing(int id, int ep, int at,
|
||||
{bool adult = false, String format = 'TV', String? english = 'Show', String? color = '#e4a15d'}) =>
|
||||
{
|
||||
'airingAt': at,
|
||||
'episode': ep,
|
||||
'media': {
|
||||
'id': id,
|
||||
'title': {'romaji': 'Romaji $id', 'english': english},
|
||||
'coverImage': {'large': 'https://img/$id.jpg', 'color': color},
|
||||
'bannerImage': null,
|
||||
'isAdult': adult,
|
||||
'popularity': id * 10,
|
||||
'format': format,
|
||||
},
|
||||
};
|
||||
|
||||
Map<String, Object?> page(List<Object?> rows, {bool next = false}) => {
|
||||
'data': {
|
||||
'Page': {
|
||||
'pageInfo': {'hasNextPage': next},
|
||||
'airingSchedules': rows,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
late FakeServer server;
|
||||
late Directory cache;
|
||||
var now = DateTime.utc(2026, 10, 8, 12);
|
||||
final delays = <Duration>[];
|
||||
final from = DateTime.utc(2026, 9, 28), to = DateTime.utc(2026, 10, 26);
|
||||
|
||||
AniListClient client() => AniListClient(
|
||||
url: server.url,
|
||||
cacheDir: () async => cache,
|
||||
delay: (d) async => delays.add(d),
|
||||
now: () => now,
|
||||
userAgent: 'watch-together/test',
|
||||
);
|
||||
|
||||
setUp(() async {
|
||||
now = DateTime.utc(2026, 10, 8, 12);
|
||||
delays.clear();
|
||||
cache = await Directory.systemTemp.createTemp('anilist-test');
|
||||
server = await FakeServer.start((req, res) async {
|
||||
final vars = (jsonDecode(req.body) as Map)['variables'] as Map;
|
||||
final p = vars['page'] as int;
|
||||
json(res, page([
|
||||
if (p == 1) ...[airing(1, 5, 1759900000), airing(2, 1, 1759910000, adult: true)],
|
||||
if (p == 2) ...[airing(3, 2, 1759920000, format: 'MUSIC'), airing(4, 12, 1759930000, english: null)],
|
||||
], next: p == 1));
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close();
|
||||
await cache.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('pages, filters adult and music, parses fields', () async {
|
||||
final c = client();
|
||||
final s = await c.schedule(from, to);
|
||||
expect(server.seen, hasLength(2));
|
||||
final first = jsonDecode(server.seen.first.body) as Map;
|
||||
expect(first['variables'], {'from': from.millisecondsSinceEpoch ~/ 1000 - 1, 'to': to.millisecondsSinceEpoch ~/ 1000, 'page': 1});
|
||||
expect(server.seen.first.headers.value(HttpHeaders.userAgentHeader), 'watch-together/test');
|
||||
expect(s.airings.map((a) => a.anilistId), [1, 4]);
|
||||
final a = s.airings.first;
|
||||
expect(a.title, 'Show');
|
||||
expect(a.titleRomaji, 'Romaji 1');
|
||||
expect(a.episode, 5);
|
||||
expect(a.airingAt, DateTime.fromMillisecondsSinceEpoch(1759900000000, isUtc: true));
|
||||
expect(a.airingAt.isUtc, isTrue);
|
||||
expect(a.cover, 'https://img/1.jpg');
|
||||
expect(a.color?.toARGB32(), 0xffe4a15d);
|
||||
expect(a.popularity, 10);
|
||||
expect(s.airings.last.title, 'Romaji 4'); // no English title
|
||||
expect(s.isStale, isFalse);
|
||||
c.close();
|
||||
});
|
||||
|
||||
test('memory cache, then disk cache across clients, expiry refetches', () async {
|
||||
final c = client();
|
||||
await c.schedule(from, to);
|
||||
await c.schedule(from, to);
|
||||
expect(server.seen, hasLength(2));
|
||||
now = now.add(const Duration(minutes: 11)); // memory stale, disk fresh
|
||||
await c.schedule(from, to);
|
||||
expect(server.seen, hasLength(2));
|
||||
final c2 = client(); // restart
|
||||
await c2.schedule(from, to);
|
||||
expect(server.seen, hasLength(2));
|
||||
now = now.add(const Duration(hours: 1));
|
||||
await c2.schedule(from, to);
|
||||
expect(server.seen, hasLength(4));
|
||||
// Another range start is another key.
|
||||
await c2.schedule(from.add(const Duration(days: 7)), to);
|
||||
expect(server.seen, hasLength(6));
|
||||
c.close();
|
||||
c2.close();
|
||||
});
|
||||
|
||||
test('429 waits Retry-After once, then succeeds', () async {
|
||||
var n = 0;
|
||||
final ok = server.handler;
|
||||
server.handler = (req, res) async {
|
||||
if (n++ == 0) {
|
||||
res.statusCode = 429;
|
||||
res.headers.set(HttpHeaders.retryAfterHeader, '7');
|
||||
res.write('{"errors":[{"message":"Too Many Requests."}]}');
|
||||
return;
|
||||
}
|
||||
await ok(req, res);
|
||||
};
|
||||
final s = await client().schedule(from, to);
|
||||
expect(delays, [const Duration(seconds: 7)]);
|
||||
expect(s.airings, hasLength(2));
|
||||
});
|
||||
|
||||
test('429 twice fails', () async {
|
||||
server.handler = (req, res) async {
|
||||
res.statusCode = 429;
|
||||
res.headers.set(HttpHeaders.retryAfterHeader, '2');
|
||||
};
|
||||
await expectLater(
|
||||
client().schedule(from, to),
|
||||
throwsA(isA<AniListException>().having((e) => e.isRateLimited, 'rate limited', isTrue)));
|
||||
expect(delays, hasLength(1));
|
||||
expect(server.seen, hasLength(2));
|
||||
});
|
||||
|
||||
test('failure returns stale cache with its fetch time', () async {
|
||||
final c = client();
|
||||
final fetched = now;
|
||||
await c.schedule(from, to);
|
||||
now = now.add(const Duration(hours: 2));
|
||||
server.handler = (req, res) async => json(res, {'errors': [{'message': 'down'}]}, status: 500);
|
||||
final s = await c.schedule(from, to);
|
||||
expect(s.isStale, isTrue);
|
||||
expect(s.staleSince, fetched);
|
||||
expect(s.airings, hasLength(2));
|
||||
// A fresh client (restart) falls back to the disk copy.
|
||||
final s2 = await client().schedule(from, to);
|
||||
expect(s2.staleSince!.isAtSameMomentAs(fetched), isTrue);
|
||||
});
|
||||
|
||||
test('failure without cache throws', () async {
|
||||
server.handler = (req, res) async => json(res, {'errors': [{'message': 'Bad'}]}, status: 400);
|
||||
await expectLater(client().schedule(from, to),
|
||||
throwsA(isA<AniListException>().having((e) => e.message, 'message', contains('Bad'))));
|
||||
});
|
||||
|
||||
test('unreachable is an AniListException', () async {
|
||||
await server.close();
|
||||
await expectLater(client().schedule(from, to), throwsA(isA<AniListException>()));
|
||||
});
|
||||
|
||||
test('parseHexColor', () {
|
||||
expect(parseHexColor('#000000')?.toARGB32(), 0xff000000);
|
||||
expect(parseHexColor('e4a15d'), isNull);
|
||||
expect(parseHexColor(null), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/schedule/animeflow.dart';
|
||||
|
||||
import 'schedule_fake_server.dart';
|
||||
|
||||
const token = 'test-token';
|
||||
|
||||
final episodeJson = {
|
||||
'season': 2,
|
||||
'episode': 3,
|
||||
'absolute': 15,
|
||||
'episode_title': 'Three',
|
||||
'air_date': '2026-10-08T19:36:32Z',
|
||||
'anilist_id': 222,
|
||||
'status': 'downloading',
|
||||
'progress': 0.5,
|
||||
};
|
||||
|
||||
final seriesJson = {
|
||||
'series_id': 7,
|
||||
'title': 'Show',
|
||||
'anidb_id': 70,
|
||||
'tvdb_id': 700,
|
||||
'anilist_id': 111,
|
||||
'jellyfin_series_id': 'jf7',
|
||||
'state': 'released',
|
||||
'monitored': true,
|
||||
'followed': true,
|
||||
'episodes_total': 24,
|
||||
'episodes_available': 20,
|
||||
'next_air_date': null,
|
||||
};
|
||||
|
||||
final requestJson = {
|
||||
'id': 9,
|
||||
'kind': 'season',
|
||||
'title': 'Show S2',
|
||||
'series_id': 7,
|
||||
'season': 2,
|
||||
'status': 'searching',
|
||||
'created_at': '2026-10-01T10:00:00Z',
|
||||
'updated_at': '2026-10-02T10:00:00Z',
|
||||
};
|
||||
|
||||
void main() {
|
||||
late FakeServer server;
|
||||
final delays = <Duration>[];
|
||||
var now = DateTime.utc(2026, 10, 8);
|
||||
|
||||
AnimeFlowClient client({String? tok = token}) => AnimeFlowClient(
|
||||
baseUrl: '${server.url}/api/app/v1',
|
||||
token: tok,
|
||||
delay: (d) async => delays.add(d),
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
/// Routes "METHOD /path" (below the base) to a handler.
|
||||
Future<void> serve(Map<String, FakeRoute> routes) async {
|
||||
server.handler = (req, res) async {
|
||||
final path = req.uri.path.replaceFirst('/api/app/v1', '');
|
||||
final h = routes['${req.method} $path'];
|
||||
if (h == null) return json(res, {'detail': 'Not found', 'code': 'not_found'}, status: 404);
|
||||
await h(req, res);
|
||||
};
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
delays.clear();
|
||||
now = DateTime.utc(2026, 10, 8);
|
||||
server = await FakeServer.start((req, res) async {});
|
||||
});
|
||||
|
||||
tearDown(() => server.close());
|
||||
|
||||
test('/me: token header, scopes, cached for a minute', () async {
|
||||
await serve({
|
||||
'GET /me': (req, res) async =>
|
||||
json(res, {'user_id': 'u1', 'name': 'Foxyz', 'scopes': ['view', 'request', 'bogus']}),
|
||||
});
|
||||
final c = client();
|
||||
final me = await c.me();
|
||||
expect(server.seen.single.headers.value('x-emby-token'), token);
|
||||
expect(server.seen.single.headers.value(HttpHeaders.userAgentHeader), startsWith('watch-together/'));
|
||||
expect(me.userId, 'u1');
|
||||
expect(me.name, 'Foxyz');
|
||||
expect(me.scopes, {AnimeFlowScope.view, AnimeFlowScope.request});
|
||||
expect(me.can(AnimeFlowScope.manage), isFalse);
|
||||
await c.me();
|
||||
expect(server.seen, hasLength(1));
|
||||
now = now.add(const Duration(seconds: 61));
|
||||
await c.me();
|
||||
expect(server.seen, hasLength(2));
|
||||
});
|
||||
|
||||
test('/schedule: query in UTC ISO, items parsed, ETag/304 returns cached body', () async {
|
||||
var etagHits = 0;
|
||||
await serve({
|
||||
'GET /schedule': (req, res) async {
|
||||
if (req.headers.value(HttpHeaders.ifNoneMatchHeader) == 'W/"s1"') {
|
||||
etagHits++;
|
||||
res.statusCode = 304;
|
||||
return;
|
||||
}
|
||||
json(res, {
|
||||
'from': req.uri.queryParameters['from'],
|
||||
'to': req.uri.queryParameters['to'],
|
||||
'items': [
|
||||
{...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700,
|
||||
'jellyfin_series_id': 'jf7', 'followed': true},
|
||||
],
|
||||
}, etag: 'W/"s1"');
|
||||
},
|
||||
});
|
||||
final c = client();
|
||||
final from = DateTime.utc(2026, 9, 28), to = DateTime.utc(2026, 10, 26);
|
||||
final w = await c.schedule(from, to);
|
||||
expect(server.seen.first.uri.queryParameters, {'from': '2026-09-28T00:00:00Z', 'to': '2026-10-26T00:00:00Z'});
|
||||
expect(w.from, from);
|
||||
final it = w.items.single;
|
||||
expect(it.seriesId, 7);
|
||||
expect(it.title, 'Show');
|
||||
expect(it.anidbId, 70);
|
||||
expect(it.tvdbId, 700);
|
||||
expect(it.jellyfinSeriesId, 'jf7');
|
||||
expect(it.followed, isTrue);
|
||||
final e = it.episode;
|
||||
expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222));
|
||||
expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32));
|
||||
expect(e.status, EpisodeStatus.downloading);
|
||||
expect(e.progress, 0.5);
|
||||
final again = await c.schedule(from, to);
|
||||
expect(etagHits, 1);
|
||||
expect(again.items.single.episode.anilistId, 222);
|
||||
});
|
||||
|
||||
test('/schedule range checked locally', () async {
|
||||
final c = client();
|
||||
final from = DateTime.utc(2026, 10, 1);
|
||||
await expectLater(c.schedule(from, from),
|
||||
throwsA(isA<AnimeFlowException>().having((e) => e.code, 'code', AnimeFlowErrorCode.invalidRange)));
|
||||
await expectLater(c.schedule(from, from.add(const Duration(days: 46))), throwsA(isA<AnimeFlowException>()));
|
||||
expect(server.seen, isEmpty);
|
||||
});
|
||||
|
||||
test('/series, /series/{id} with episodes, follow/unfollow', () async {
|
||||
await serve({
|
||||
'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'),
|
||||
'GET /series/7': (req, res) async => json(res, {
|
||||
...seriesJson,
|
||||
'episodes': [
|
||||
{'season': 0, 'episode': 1, 'status': 'available'},
|
||||
{'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'},
|
||||
],
|
||||
}),
|
||||
'PUT /series/7/follow': (req, res) async => res.statusCode = 204,
|
||||
'DELETE /series/7/follow': (req, res) async => res.statusCode = 204,
|
||||
});
|
||||
final c = client();
|
||||
final s = (await c.series()).single;
|
||||
expect(s.seriesId, 7);
|
||||
expect(s.anilistId, 111);
|
||||
expect(s.state, SeriesState.released);
|
||||
expect(s.monitored && s.followed, isTrue);
|
||||
expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null));
|
||||
final d = await c.seriesDetail(7);
|
||||
expect(d.episodes.map((e) => e.season), [0, 1]);
|
||||
expect(d.episodes.last.status, EpisodeStatus.failed);
|
||||
expect(d.episodes.last.reason, 'No release');
|
||||
await c.setFollowed(7, true);
|
||||
await c.setFollowed(7, false);
|
||||
expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'),
|
||||
['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']);
|
||||
});
|
||||
|
||||
test('/search and the three request bodies', () async {
|
||||
await serve({
|
||||
'GET /search': (req, res) async => json(res, {
|
||||
'items': [
|
||||
{'anidb_id': 70, 'title': 'Show', 'anilist_id': 111, 'series_id': 7},
|
||||
{'anidb_id': 71, 'title': 'Other'},
|
||||
],
|
||||
}),
|
||||
'POST /requests': (req, res) async => json(res, {...requestJson, ...jsonDecode(req.body) as Map}, status: 202),
|
||||
});
|
||||
final c = client();
|
||||
final hits = await c.search('show');
|
||||
expect(server.seen.single.uri.queryParameters['q'], 'show');
|
||||
expect(hits.map((h) => (h.anidbId, h.title, h.anilistId, h.seriesId)), [(70, 'Show', 111, 7), (71, 'Other', null, null)]);
|
||||
final r = await c.requestSeries(70);
|
||||
expect(r.id, 9);
|
||||
expect(r.status, RequestStatus.searching);
|
||||
await c.requestSeason(7, 2);
|
||||
await c.requestEpisode(7, 2, 3);
|
||||
expect(server.seen.skip(1).map((r) => jsonDecode(r.body)), [
|
||||
{'anidb_id': 70},
|
||||
{'series_id': 7, 'season': 2},
|
||||
{'series_id': 7, 'season': 2, 'episode': 3},
|
||||
]);
|
||||
expect(server.seen.last.headers.contentType?.mimeType, 'application/json');
|
||||
});
|
||||
|
||||
test('/requests own and all, approve, reject', () async {
|
||||
await serve({
|
||||
'GET /requests': (req, res) async => json(res, {
|
||||
'items': [
|
||||
if (req.uri.queryParameters['scope'] == 'all') {...requestJson, 'user_id': 'u2', 'user_name': 'Bob'}
|
||||
else requestJson,
|
||||
],
|
||||
}),
|
||||
'POST /requests/9/approve': (req, res) async => json(res, {...requestJson, 'status': 'approved'}),
|
||||
'POST /requests/9/reject': (req, res) async => res.statusCode = 204,
|
||||
});
|
||||
final c = client();
|
||||
final own = (await c.requests()).single;
|
||||
expect(own.kind, RequestKind.season);
|
||||
expect((own.seriesId, own.season, own.episode, own.userName), (7, 2, null, null));
|
||||
expect(own.createdAt, DateTime.utc(2026, 10, 1, 10));
|
||||
final all = (await c.requests(all: true)).single;
|
||||
expect(all.userName, 'Bob');
|
||||
expect((await c.approve(9))?.status, RequestStatus.approved);
|
||||
expect(await c.reject(9, reason: 'nope'), isNull);
|
||||
expect(jsonDecode(server.seen.last.body), {'reason': 'nope'});
|
||||
await c.reject(9);
|
||||
expect(jsonDecode(server.seen.last.body), <String, Object?>{});
|
||||
});
|
||||
|
||||
test('/users and scopes', () async {
|
||||
await serve({
|
||||
'GET /users': (req, res) async => json(res, {
|
||||
'items': [
|
||||
{'user_id': 'u2', 'name': 'Bob', 'scopes': ['view'], 'revoked': true, 'last_seen_at': '2026-10-07T08:00:00Z'},
|
||||
],
|
||||
}),
|
||||
'PUT /users/u2/scopes': (req, res) async => res.statusCode = 204,
|
||||
});
|
||||
final c = client();
|
||||
final u = (await c.users()).single;
|
||||
expect((u.userId, u.name, u.revoked), ('u2', 'Bob', true));
|
||||
expect(u.scopes, {AnimeFlowScope.view});
|
||||
expect(u.lastSeenAt, DateTime.utc(2026, 10, 7, 8));
|
||||
await c.setScopes('u2', {AnimeFlowScope.request, AnimeFlowScope.view}, revoked: false);
|
||||
expect(jsonDecode(server.seen.last.body), {'scopes': ['view', 'request'], 'revoked': false});
|
||||
});
|
||||
|
||||
test('error codes map to typed exceptions', () async {
|
||||
const cases = {
|
||||
'missing_token': (401, AnimeFlowErrorCode.missingToken),
|
||||
'bad_token': (401, AnimeFlowErrorCode.badToken),
|
||||
'revoked': (403, AnimeFlowErrorCode.revoked),
|
||||
'missing_scope': (403, AnimeFlowErrorCode.missingScope),
|
||||
'own_account': (403, AnimeFlowErrorCode.ownAccount),
|
||||
'jellyfin_unreachable': (503, AnimeFlowErrorCode.jellyfinUnreachable),
|
||||
'search_unavailable': (503, AnimeFlowErrorCode.searchUnavailable),
|
||||
'invalid_request': (400, AnimeFlowErrorCode.invalidRequest),
|
||||
'invalid_time': (400, AnimeFlowErrorCode.invalidTime),
|
||||
'invalid_range': (400, AnimeFlowErrorCode.invalidRange),
|
||||
'not_found': (404, AnimeFlowErrorCode.notFound),
|
||||
'series_not_found': (404, AnimeFlowErrorCode.seriesNotFound),
|
||||
'episode_not_found': (404, AnimeFlowErrorCode.episodeNotFound),
|
||||
'request_not_found': (404, AnimeFlowErrorCode.requestNotFound),
|
||||
'user_not_found': (404, AnimeFlowErrorCode.userNotFound),
|
||||
'already_tracked': (409, AnimeFlowErrorCode.alreadyTracked),
|
||||
'already_available': (409, AnimeFlowErrorCode.alreadyAvailable),
|
||||
'already_requested': (409, AnimeFlowErrorCode.alreadyRequested),
|
||||
'invalid_state': (409, AnimeFlowErrorCode.invalidState),
|
||||
'something_new': (409, AnimeFlowErrorCode.invalidState), // falls back by status
|
||||
};
|
||||
final c = client();
|
||||
for (final MapEntry(key: wire, value: (status, code)) in cases.entries) {
|
||||
server.handler = (req, res) async => json(res, {'detail': 'because $wire', 'code': wire}, status: status);
|
||||
try {
|
||||
await c.series();
|
||||
fail('no throw for $wire');
|
||||
} on AnimeFlowException catch (e) {
|
||||
expect((e.statusCode, e.code, e.detail), (status, code, 'because $wire'), reason: wire);
|
||||
}
|
||||
}
|
||||
expect(delays, isEmpty);
|
||||
});
|
||||
|
||||
test('nginx HTML 429 backs off, then succeeds or gives up', () async {
|
||||
var n = 0;
|
||||
server.handler = (req, res) async {
|
||||
if (n++ < 2) {
|
||||
res.statusCode = 429;
|
||||
res.headers.contentType = ContentType.html;
|
||||
res.write('<html><body><h1>429 Too Many Requests</h1></body></html>');
|
||||
return;
|
||||
}
|
||||
json(res, {'items': []});
|
||||
};
|
||||
final c = client();
|
||||
expect(await c.series(), isEmpty);
|
||||
expect(delays, [const Duration(seconds: 1), const Duration(seconds: 2)]);
|
||||
n = -10;
|
||||
delays.clear();
|
||||
await expectLater(
|
||||
c.series(),
|
||||
throwsA(isA<AnimeFlowException>()
|
||||
.having((e) => e.isRateLimited, 'rate limited', isTrue)
|
||||
.having((e) => e.statusCode, 'status', 429)));
|
||||
expect(delays, hasLength(AnimeFlowClient.rateLimitRetries));
|
||||
});
|
||||
|
||||
test('no token: missing_token without a request; oversized body refused', () async {
|
||||
await expectLater(client(tok: null).series(),
|
||||
throwsA(isA<AnimeFlowException>().having((e) => e.code, 'code', AnimeFlowErrorCode.missingToken)));
|
||||
await expectLater(client().reject(1, reason: 'x' * 17000),
|
||||
throwsA(isA<AnimeFlowException>().having((e) => e.code, 'code', AnimeFlowErrorCode.invalidRequest)));
|
||||
expect(server.seen, isEmpty);
|
||||
});
|
||||
|
||||
test('unreachable is a network error', () async {
|
||||
await server.close();
|
||||
await expectLater(client().me(),
|
||||
throwsA(isA<AnimeFlowException>().having((e) => e.code, 'code', AnimeFlowErrorCode.network)));
|
||||
});
|
||||
|
||||
test('tokenFor picks the account on the same parent domain', () {
|
||||
JellyfinAccount acc(String id, String url) =>
|
||||
JellyfinAccount(serverId: id, serverName: id, urls: [url], userId: 'u', userName: 'n', token: 'tok-$id');
|
||||
final accounts = [
|
||||
acc('a', 'https://jellyfin.other.org'),
|
||||
acc('b', 'https://media.senkochan.moe:8920'),
|
||||
];
|
||||
expect(AnimeFlowClient.tokenFor(accounts, animeFlowDefaultBase), 'tok-b');
|
||||
expect(AnimeFlowClient.tokenFor([acc('c', 'https://senkochan.moe')], animeFlowDefaultBase), 'tok-c');
|
||||
expect(AnimeFlowClient.tokenFor([acc('d', 'https://notsenkochan.moe')], animeFlowDefaultBase), isNull);
|
||||
expect(AnimeFlowClient.tokenFor(accounts, 'https://anime.example.net/api'), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// One request the fake server saw.
|
||||
class SeenRequest {
|
||||
final String method;
|
||||
final Uri uri;
|
||||
final HttpHeaders headers;
|
||||
final String body;
|
||||
SeenRequest(this.method, this.uri, this.headers, this.body);
|
||||
}
|
||||
|
||||
typedef FakeRoute = Future<void> Function(SeenRequest req, HttpResponse res);
|
||||
|
||||
/// A localhost HTTP server for the Schedule clients: [handler] answers,
|
||||
/// [seen] records every request.
|
||||
class FakeServer {
|
||||
FakeServer._(this._server) : url = 'http://${_server.address.host}:${_server.port}';
|
||||
final HttpServer _server;
|
||||
/// Kept after [close], so clients can try an address nobody answers.
|
||||
final String url;
|
||||
final seen = <SeenRequest>[];
|
||||
late FakeRoute handler;
|
||||
|
||||
static Future<FakeServer> start(FakeRoute handler) async {
|
||||
final s = FakeServer._(await HttpServer.bind(InternetAddress.loopbackIPv4, 0))..handler = handler;
|
||||
s._server.listen((req) async {
|
||||
final body = await utf8.decodeStream(req);
|
||||
final r = SeenRequest(req.method, req.uri, req.headers, body);
|
||||
s.seen.add(r);
|
||||
try {
|
||||
await s.handler(r, req.response);
|
||||
} catch (e, st) {
|
||||
// ignore: avoid_print
|
||||
print('fake server handler failed: $e\n$st');
|
||||
req.response.statusCode = 599;
|
||||
}
|
||||
await req.response.close();
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
Future<void> close() => _server.close(force: true);
|
||||
}
|
||||
|
||||
void json(HttpResponse res, Object? body, {int status = 200, String? etag}) {
|
||||
res.statusCode = status;
|
||||
res.headers.contentType = ContentType.json;
|
||||
if (etag != null) res.headers.set(HttpHeaders.etagHeader, etag);
|
||||
res.write(jsonEncode(body));
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/schedule/anilist.dart';
|
||||
import 'package:watch_together/src/schedule/animeflow.dart';
|
||||
import 'package:watch_together/src/schedule/schedule_model.dart';
|
||||
|
||||
import 'schedule_fake_server.dart';
|
||||
|
||||
// Local times, so the "same local air day" rule is timezone-proof.
|
||||
final now = DateTime(2026, 10, 8, 12);
|
||||
|
||||
Airing airing(int id, int ep, DateTime local) => Airing(
|
||||
anilistId: id,
|
||||
title: 'Show $id',
|
||||
titleRomaji: 'Show $id',
|
||||
episode: ep,
|
||||
airingAt: local.toUtc(),
|
||||
);
|
||||
|
||||
AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
|
||||
{int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false,
|
||||
double? progress, String? reason}) =>
|
||||
AnimeFlowScheduleItem(
|
||||
episode: AnimeFlowEpisode(
|
||||
season: season,
|
||||
episode: ep,
|
||||
absolute: absolute,
|
||||
airDate: air?.toUtc(),
|
||||
anilistId: anilistId,
|
||||
status: status,
|
||||
progress: progress,
|
||||
reason: reason,
|
||||
),
|
||||
seriesId: seriesId,
|
||||
title: 'Show',
|
||||
jellyfinSeriesId: 'jf$seriesId',
|
||||
followed: followed,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('matchSchedule', () {
|
||||
test('by AniList id + episode, per season ids', () {
|
||||
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
|
||||
final out = matchSchedule(
|
||||
[airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))],
|
||||
schedule: [
|
||||
item(100, 3, EpisodeStatus.available, season: 1),
|
||||
item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true),
|
||||
],
|
||||
series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')],
|
||||
now: now,
|
||||
);
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]);
|
||||
expect(out.map((e) => e.season), [1, 2]);
|
||||
expect(out[1].progress, 0.4);
|
||||
expect(out[1].followed, isTrue);
|
||||
expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue);
|
||||
});
|
||||
|
||||
test('episode numbers do not cross AniList ids', () {
|
||||
final out = matchSchedule(
|
||||
[airing(200, 1, DateTime(2026, 10, 2, 20))],
|
||||
schedule: [item(100, 1, EpisodeStatus.available)],
|
||||
now: now,
|
||||
);
|
||||
expect(out.single.status, ScheduleStatus.notTracked);
|
||||
expect(out.single.tracked, isFalse);
|
||||
});
|
||||
|
||||
test('absolute number, then same local air day', () {
|
||||
final out = matchSchedule(
|
||||
[
|
||||
airing(300, 1100, DateTime(2026, 10, 5, 9)), // AniList counts absolute
|
||||
airing(400, 13, DateTime(2026, 10, 6, 23, 30)), // numbering differs
|
||||
],
|
||||
schedule: [
|
||||
item(300, 5, EpisodeStatus.available, season: 22, absolute: 1100),
|
||||
item(400, 1, EpisodeStatus.failed, season: 2, air: DateTime(2026, 10, 6, 0, 15), reason: 'No release'),
|
||||
],
|
||||
now: now,
|
||||
);
|
||||
expect(out[0].status, ScheduleStatus.available);
|
||||
expect(out[0].season, 22);
|
||||
expect(out[1].status, ScheduleStatus.failed);
|
||||
expect(out[1].reason, 'No release');
|
||||
});
|
||||
|
||||
test('a different local day does not match', () {
|
||||
final out = matchSchedule(
|
||||
[airing(400, 13, DateTime(2026, 10, 7, 0, 30))],
|
||||
schedule: [item(400, 1, EpisodeStatus.available, air: DateTime(2026, 10, 6, 23, 50))],
|
||||
now: now,
|
||||
);
|
||||
// Tracked (the id is in the schedule) but unmatched and aired.
|
||||
expect(out.single.status, ScheduleStatus.missing);
|
||||
expect(out.single.tracked, isTrue);
|
||||
});
|
||||
|
||||
test('tracked but unmatched: missing once aired, upcoming before', () {
|
||||
final out = matchSchedule(
|
||||
[airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))],
|
||||
schedule: const [],
|
||||
series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)],
|
||||
now: now,
|
||||
);
|
||||
expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]);
|
||||
expect(out.every((e) => e.followed && e.seriesId == 9), isTrue);
|
||||
});
|
||||
|
||||
test('progress only while downloading, reason only when failed', () {
|
||||
final out = matchSchedule(
|
||||
[airing(600, 1, DateTime(2026, 10, 1, 20))],
|
||||
schedule: [item(600, 1, EpisodeStatus.available, progress: 1, reason: 'x')],
|
||||
now: now,
|
||||
);
|
||||
expect(out.single.progress, isNull);
|
||||
expect(out.single.reason, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('defaultWindow: last week Monday, 28 days', () {
|
||||
final w = ScheduleRepository.defaultWindow(DateTime(2026, 10, 8, 15)); // Thursday
|
||||
expect(w.from, DateTime(2026, 9, 28));
|
||||
expect(w.to, DateTime(2026, 10, 26));
|
||||
final mon = ScheduleRepository.defaultWindow(DateTime(2026, 10, 5, 1));
|
||||
expect(mon.from, DateTime(2026, 9, 28));
|
||||
});
|
||||
|
||||
group('ScheduleRepository', () {
|
||||
late FakeServer server;
|
||||
late Directory cache;
|
||||
var aniDown = false;
|
||||
var flowStatus = 200;
|
||||
final at = DateTime(2026, 10, 1, 20).toUtc();
|
||||
|
||||
setUp(() async {
|
||||
aniDown = false;
|
||||
flowStatus = 200;
|
||||
cache = await Directory.systemTemp.createTemp('schedule-repo-test');
|
||||
server = await FakeServer.start((req, res) async {
|
||||
if (req.uri.path == '/anilist') {
|
||||
if (aniDown) return json(res, {'errors': [{'message': 'down'}]}, status: 500);
|
||||
return json(res, {
|
||||
'data': {
|
||||
'Page': {
|
||||
'pageInfo': {'hasNextPage': false},
|
||||
'airingSchedules': [
|
||||
for (final (id, ep, t) in [(100, 3, at), (900, 1, at.add(const Duration(days: 20))), (901, 1, at)])
|
||||
{
|
||||
'airingAt': t.millisecondsSinceEpoch ~/ 1000,
|
||||
'episode': ep,
|
||||
'media': {'id': id, 'title': {'romaji': 'R$id'}, 'isAdult': false, 'format': 'TV'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (flowStatus != 200) {
|
||||
return json(res, {'detail': 'Jellyfin is down', 'code': 'jellyfin_unreachable'}, status: flowStatus);
|
||||
}
|
||||
switch (req.uri.path) {
|
||||
case '/flow/me':
|
||||
return json(res, {'user_id': 'u', 'name': 'Me', 'scopes': ['view']});
|
||||
case '/flow/schedule':
|
||||
return json(res, {
|
||||
'items': [
|
||||
{'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7,
|
||||
'title': 'Show', 'followed': false},
|
||||
],
|
||||
});
|
||||
case '/flow/series':
|
||||
return json(res, {'items': []});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close();
|
||||
await cache.delete(recursive: true);
|
||||
});
|
||||
|
||||
ScheduleRepository repo({bool flow = true}) => ScheduleRepository(
|
||||
aniList: AniListClient(url: '${server.url}/anilist', cacheDir: () async => cache, now: () => now),
|
||||
animeFlow: flow ? AnimeFlowClient(baseUrl: '${server.url}/flow', token: 'tok', now: () => now) : null,
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
test('both ok: merged, sorted', () async {
|
||||
final s = await repo().load();
|
||||
expect(s.aniListError, isNull);
|
||||
expect(s.animeFlowError, isNull);
|
||||
expect(s.me?.name, 'Me');
|
||||
expect(s.entries.map((e) => (e.airing.anilistId, e.status)), [
|
||||
(100, ScheduleStatus.available),
|
||||
(901, ScheduleStatus.notTracked),
|
||||
(900, ScheduleStatus.notTracked),
|
||||
]);
|
||||
final aniReq = server.seen.firstWhere((r) => r.uri.path == '/anilist');
|
||||
final vars = (jsonDecode(aniReq.body) as Map)['variables'] as Map;
|
||||
expect(vars['from'], DateTime(2026, 9, 28).millisecondsSinceEpoch ~/ 1000 - 1);
|
||||
final sched = server.seen.firstWhere((r) => r.uri.path == '/flow/schedule');
|
||||
expect(sched.uri.queryParameters['from'], animeFlowTime(DateTime(2026, 9, 28)));
|
||||
});
|
||||
|
||||
test('AnimeFlow failing: AniList-only statuses and an error', () async {
|
||||
flowStatus = 503;
|
||||
final s = await repo().load();
|
||||
expect(s.animeFlowException?.code, AnimeFlowErrorCode.jellyfinUnreachable);
|
||||
expect(s.animeFlowError, contains('Jellyfin is down'));
|
||||
expect(s.me, isNull);
|
||||
expect(s.entries.map((e) => (e.airing.anilistId, e.status, e.tracked)), [
|
||||
(100, ScheduleStatus.notTracked, false),
|
||||
(901, ScheduleStatus.notTracked, false),
|
||||
(900, ScheduleStatus.upcoming, false),
|
||||
]);
|
||||
});
|
||||
|
||||
test('AnimeFlow not configured', () async {
|
||||
final s = await repo(flow: false).load();
|
||||
expect(s.animeFlowError, isNotNull);
|
||||
expect(s.entries, hasLength(3));
|
||||
});
|
||||
|
||||
test('AniList failing: stale cache, else an error', () async {
|
||||
final r = repo();
|
||||
aniDown = true;
|
||||
final fail = await r.load();
|
||||
expect(fail.entries, isEmpty);
|
||||
expect(fail.aniListError, contains('500'));
|
||||
aniDown = false;
|
||||
await r.load();
|
||||
aniDown = true;
|
||||
final stale = await r.load(force: true);
|
||||
expect(stale.aniListStaleSince, isNotNull);
|
||||
expect(stale.entries, hasLength(3));
|
||||
expect(stale.entries.first.status, ScheduleStatus.available);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user