Files
plezy/test/providers/offline_mode_provider_test.dart
T
edde746 4c8272d5b1 refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService
re-implemented the whole playback lifecycle beside TrackerCoordinator, and
TraktSyncService pushed watched state from its own WatchStateNotifier
subscription, so the player called two objects at every lifecycle point and one
watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker
like Simkl; the duplicated player call sites collapse to one each, and Trakt
shares the coordinator's ID resolver instead of re-fetching show ids every
episode.

Capabilities are split so a tracker declares what it is rather than being
special-cased: ScrobblePolicy carries each service's own resend/seek rules,
EpisodeHistoryTracker names the remote row a per-item history write targets, and
SeriesProgressTracker covers one-counter-per-series services. Writes from all
four trackers go through a shared TrackerWriteQueue, generalised from the
Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes
the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one
object owns the active session per profile.

Two failure paths found while consolidating are fixed here too.

The queue's retries only ran on profile bind, connect and app foreground, so a
network blip mid-session left queued watches waiting for the next foreground.
OfflineModeProvider now notifies on connectivity changes, not just offline-state
or WiFi-flag changes, and main.dart flushes the queue when the network returns.

The queue also counted every failure toward the five attempts that permanently
drop an item, so a rate limit or a service having a bad hour could discard a
pending watch - the loss the queue exists to prevent. Only an answer about the
write itself now spends an attempt: 4xx counts, while rate limits, 5xx,
recoverable token-refresh failures and requests that never arrived do not. A
back-off answer also defers that service for the rest of the flush, so a queue
holding many rows does not fire all of them at a service that just asked for
quiet.
2026-07-30 14:51:32 +02:00

285 lines
10 KiB
Dart

import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/providers/offline_mode_provider.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_auth_service.dart';
import '../test_helpers/multi_server_fixtures.dart';
import '../test_helpers/prefs.dart';
void main() {
// OfflineModeProvider depends on a MultiServerManager. We instantiate one with
// no connected servers — this exercises only the in-memory bookkeeping (id
// maps + status stream) and never opens an HTTP socket. Network paths
// (initialize/refresh's connectivity_plus call) are skipped: the
// MissingPluginException in tests is already swallowed by the provider's
// try/catch, so we don't drive `initialize()` here.
setUp(resetSharedPreferencesForTest);
group('OfflineModeProvider', () {
test('with empty manager: hasServerConnection=false but isOffline stays false during warmup', () {
final manager = MultiServerManager();
final p = OfflineModeProvider(manager);
// Until [MultiServerManager] emits its first status snapshot, we don't
// actually know whether the binder will connect anything — treating an
// empty manager as offline causes the cold-start UI to flash the
// offline state for the few hundred ms it takes to come up. Stay
// optimistic.
expect(p.hasNetworkConnection, isTrue);
expect(p.hasServerConnection, isFalse);
expect(p.isOffline, isFalse);
p.dispose();
manager.dispose();
});
test('reads online server IDs from the manager at construction', () {
final manager = MultiServerManager();
manager.updateServerStatus(ServerId('srv-1'), true);
final p = OfflineModeProvider(manager);
expect(p.hasServerConnection, isTrue);
// Network is assumed up by default; both up → not offline.
expect(p.isOffline, isFalse);
p.dispose();
manager.dispose();
});
test('all servers offline at construction → still warmup-optimistic until status emits', () {
// updateServerStatus pushes to a broadcast controller — the provider
// hasn't subscribed yet, so it never sees these events. After
// construction `onlineServerIds` is empty (the same shape as a
// fresh-cold-start manager), so we stay optimistic until the
// provider's own listener catches an emission.
final manager = MultiServerManager();
manager.updateServerStatus(ServerId('srv-1'), false);
manager.updateServerStatus(ServerId('srv-2'), false);
final p = OfflineModeProvider(manager);
expect(p.hasServerConnection, isFalse);
expect(p.isOffline, isFalse);
p.dispose();
manager.dispose();
});
test('dispose marks provider as disposed; later notifies are no-ops', () {
final manager = MultiServerManager();
final p = OfflineModeProvider(manager);
p.dispose();
// After dispose, the disposable mixin guards against post-dispose notify.
// We can't call private safeNotifyListeners, but `isDisposed` reflects state.
expect(p.isDisposed, isTrue);
manager.dispose();
});
test('warmup skipped when manager already has an online server at construction', () {
// If the manager already has an online server when the provider is
// built, we have ground truth — no need for the warmup window.
// hasServerConnection reflects the manager's state and isOffline
// is correctly false (network up + server up).
final manager = MultiServerManager();
manager.updateServerStatus(ServerId('srv'), true);
final p = OfflineModeProvider(manager);
expect(p.hasServerConnection, isTrue);
expect(p.isOffline, isFalse);
p.dispose();
manager.dispose();
});
test('auth-error-only visible servers do not collapse to generic offline', () async {
final manager = MultiServerManager();
final client = JellyfinClient.forTesting(
connection: _jellyfinConnection(),
httpClient: MockClient((_) async => http.Response('', 401)),
);
manager.debugRegisterJellyfinClientForTesting(client, online: false);
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
manager.debugMarkAuthErrorForTesting(ServerId('jf-machine'));
await Future<void>.delayed(Duration.zero);
expect(multi.authErrorServerIds, contains('jf-machine'));
expect(p.isOffline, isFalse);
p.dispose();
multi.dispose();
manager.dispose();
});
test('expected but unreachable visible servers enter offline without live clients', () async {
final manager = MultiServerManager();
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
manager.updateServerStatus(ServerId('plex-server'), false);
await Future<void>.delayed(Duration.zero);
expect(p.isOffline, isFalse);
var notifications = 0;
p.addListener(() => notifications++);
multi.setExpectedVisibleServerIds({'plex-server'});
multi.setVisibleServerIds(<String>{});
await Future<void>.delayed(Duration.zero);
expect(p.isOffline, isTrue);
expect(notifications, 1);
p.dispose();
multi.dispose();
manager.dispose();
});
test('expected but unreachable profile servers enter offline once visibility settles', () async {
final manager = MultiServerManager();
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
expect(p.isOffline, isFalse);
var notifications = 0;
p.addListener(() => notifications++);
multi.setExpectedVisibleServerIds({'jf-machine'});
await Future<void>.delayed(Duration.zero);
expect(p.isOffline, isFalse);
expect(notifications, 0);
multi.setVisibleServerIds(<String>{});
await Future<void>.delayed(Duration.zero);
expect(p.isOffline, isTrue);
expect(notifications, 1);
p.dispose();
multi.dispose();
manager.dispose();
});
test('Plex auth errors without live clients stay out of generic offline', () async {
final manager = MultiServerManager();
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
await p.initialize();
multi.setExpectedVisibleServerIds({'plex-server'});
manager.markPlexConnectionAuthError(_plexConnection());
await Future<void>.delayed(Duration.zero);
expect(multi.authErrorServerIds, ['plex-server']);
expect(multi.authErrorServers.single.displayName, 'Plex');
expect(p.isOffline, isFalse);
p.dispose();
multi.dispose();
manager.dispose();
});
group('connectivity transitions', () {
test('regaining any network notifies even while servers stay unreachable', () async {
final manager = MultiServerManager();
final multi = testMultiServerProvider(manager);
final p = OfflineModeProvider(manager, multiServerProvider: multi);
// Settle visibility with nothing reachable, so offline is owned by
// `noServerConnection` rather than the network flag or startup warmup.
multi.setExpectedVisibleServerIds({'plex-server'});
multi.setVisibleServerIds({'plex-server'});
await Future<void>.delayed(Duration.zero);
p.applyConnectivityResults(const [ConnectivityResult.none]);
expect(p.hasNetworkConnection, isFalse);
expect(p.isOffline, isTrue);
var notifications = 0;
p.addListener(() => notifications++);
// Cellular comes back but no server is reachable, so neither the composite
// offline state nor the WiFi/Ethernet flag moves. Consumers that only need
// the internet — queued tracker history writes — still have to hear it.
p.applyConnectivityResults(const [ConnectivityResult.mobile]);
expect(p.hasNetworkConnection, isTrue);
expect(p.hasWifiOrEthernet, isFalse);
expect(p.isOffline, isTrue, reason: 'servers are still unreachable');
expect(notifications, 1);
p.dispose();
multi.dispose();
manager.dispose();
});
test('an unchanged connectivity snapshot notifies nobody', () async {
final manager = MultiServerManager();
final p = OfflineModeProvider(manager);
p.applyConnectivityResults(const [ConnectivityResult.wifi]);
var notifications = 0;
p.addListener(() => notifications++);
p.applyConnectivityResults(const [ConnectivityResult.wifi]);
expect(notifications, isZero);
p.dispose();
manager.dispose();
});
});
});
}
PlexAccountConnection _plexConnection() {
return PlexAccountConnection(
id: 'plex-account',
accountToken: 'account-token',
clientIdentifier: 'client-id',
accountLabel: 'Plex Account',
servers: [
PlexServer(
name: 'Plex',
clientIdentifier: 'plex-server',
accessToken: 'server-token',
connections: [
PlexConnection(
protocol: 'https',
address: 'plex.example',
port: 32400,
uri: 'https://plex.example:32400',
local: true,
relay: false,
ipv6: false,
),
],
owned: true,
),
],
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}
JellyfinConnection _jellyfinConnection() {
return JellyfinConnection(
id: 'jf-machine/user-a',
baseUrl: 'https://jellyfin.example',
serverName: 'Jellyfin',
serverMachineId: 'jf-machine',
userId: 'user-a',
userName: 'User A',
accessToken: 'token',
deviceId: 'device',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}