1096 lines
47 KiB
Dart
1096 lines
47 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:drift/drift.dart' show OrderingTerm, Value;
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/connection/connection.dart';
|
|
import 'package:plezy/connection/connection_registry.dart';
|
|
import 'package:plezy/database/app_database.dart';
|
|
import 'package:plezy/database/download_operations.dart';
|
|
import 'package:plezy/database/tvos_database_recovery_store.dart';
|
|
import 'package:plezy/media/ids.dart';
|
|
import 'package:plezy/models/download_models.dart';
|
|
import 'package:plezy/profiles/profile.dart';
|
|
import 'package:plezy/profiles/profile_connection.dart';
|
|
import 'package:plezy/profiles/profile_connection_registry.dart';
|
|
import 'package:plezy/profiles/profile_registry.dart';
|
|
import 'package:plezy/services/base_shared_preferences_service.dart';
|
|
import 'package:plezy/services/credential_vault.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
|
import 'package:shared_preferences_platform_interface/types.dart';
|
|
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
JellyfinConnection _connection(String id) => JellyfinConnection(
|
|
id: id,
|
|
baseUrl: 'https://media.invalid/$id',
|
|
serverName: 'Server $id',
|
|
serverMachineId: id,
|
|
userId: 'user-$id',
|
|
userName: 'User $id',
|
|
accessToken: 'protected-connection-canary-$id',
|
|
deviceId: 'device-$id',
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(1000),
|
|
);
|
|
|
|
Profile _profile(String id) => Profile.local(
|
|
id: id,
|
|
displayName: 'Profile $id',
|
|
pinHash: computePinHash('1234'),
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(2000),
|
|
);
|
|
|
|
final class _FailingStringPreferencesPlatform extends SharedPreferencesAsyncPlatform {
|
|
_FailingStringPreferencesPlatform(this.delegate);
|
|
|
|
final SharedPreferencesAsyncPlatform delegate;
|
|
String? failNextKey;
|
|
final Map<String, int> stringWriteAttempts = {};
|
|
|
|
Future<String?> persistedString(String key) => delegate.getString(key, const SharedPreferencesOptions());
|
|
|
|
@override
|
|
Future<void> setString(String key, String value, SharedPreferencesOptions options) async {
|
|
stringWriteAttempts[key] = (stringWriteAttempts[key] ?? 0) + 1;
|
|
if (key == failNextKey) {
|
|
failNextKey = null;
|
|
throw StateError('injected string persistence failure');
|
|
}
|
|
await delegate.setString(key, value, options);
|
|
}
|
|
|
|
@override
|
|
Future<void> setInt(String key, int value, SharedPreferencesOptions options) => delegate.setInt(key, value, options);
|
|
|
|
@override
|
|
Future<void> setBool(String key, bool value, SharedPreferencesOptions options) =>
|
|
delegate.setBool(key, value, options);
|
|
|
|
@override
|
|
Future<void> setDouble(String key, double value, SharedPreferencesOptions options) =>
|
|
delegate.setDouble(key, value, options);
|
|
|
|
@override
|
|
Future<void> setStringList(String key, List<String> value, SharedPreferencesOptions options) =>
|
|
delegate.setStringList(key, value, options);
|
|
|
|
@override
|
|
Future<String?> getString(String key, SharedPreferencesOptions options) => delegate.getString(key, options);
|
|
|
|
@override
|
|
Future<bool?> getBool(String key, SharedPreferencesOptions options) => delegate.getBool(key, options);
|
|
|
|
@override
|
|
Future<double?> getDouble(String key, SharedPreferencesOptions options) => delegate.getDouble(key, options);
|
|
|
|
@override
|
|
Future<int?> getInt(String key, SharedPreferencesOptions options) => delegate.getInt(key, options);
|
|
|
|
@override
|
|
Future<List<String>?> getStringList(String key, SharedPreferencesOptions options) =>
|
|
delegate.getStringList(key, options);
|
|
|
|
@override
|
|
Future<void> clear(ClearPreferencesParameters parameters, SharedPreferencesOptions options) =>
|
|
delegate.clear(parameters, options);
|
|
|
|
@override
|
|
Future<Map<String, Object>> getPreferences(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
|
|
delegate.getPreferences(parameters, options);
|
|
|
|
@override
|
|
Future<Set<String>> getKeys(GetPreferencesParameters parameters, SharedPreferencesOptions options) =>
|
|
delegate.getKeys(parameters, options);
|
|
}
|
|
|
|
Future<void> _deleteDatabase(File file) async {
|
|
for (final path in [file.path, '${file.path}-wal', '${file.path}-shm']) {
|
|
final candidate = File(path);
|
|
if (await candidate.exists()) await candidate.delete();
|
|
}
|
|
}
|
|
|
|
Future<List<List<Object>>> _criticalRows(AppDatabase db) async => [
|
|
await (db.select(db.connections)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(),
|
|
await (db.select(db.profiles)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(),
|
|
await (db.select(
|
|
db.profileConnections,
|
|
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get(),
|
|
await (db.select(db.offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(),
|
|
];
|
|
|
|
Future<void> _seedCriticalRows(AppDatabase db) async {
|
|
await ConnectionRegistry(db).upsert(_connection('server-1'));
|
|
await ProfileRegistry(db).upsert(_profile('local-1'));
|
|
await ProfileConnectionRegistry(db).upsert(
|
|
const ProfileConnection(
|
|
profileId: 'local-1',
|
|
connectionId: 'server-1',
|
|
userToken: 'protected-profile-token-canary',
|
|
userIdentifier: 'user-1',
|
|
),
|
|
);
|
|
|
|
await db.upsertProgressAction(
|
|
profileId: 'local-1',
|
|
serverId: ServerId('server-1'),
|
|
clientScopeId: 'server-1/user-1',
|
|
ratingKey: 'progress',
|
|
viewOffset: 1234,
|
|
duration: 9999,
|
|
shouldMarkWatched: false,
|
|
);
|
|
await db.insertWatchAction(
|
|
profileId: 'local-1',
|
|
serverId: ServerId('server-1'),
|
|
clientScopeId: 'server-1/user-1',
|
|
ratingKey: 'watched',
|
|
actionType: OfflineActionType.watched.id,
|
|
);
|
|
await db.insertWatchAction(
|
|
profileId: 'local-1',
|
|
serverId: ServerId('server-1'),
|
|
clientScopeId: 'server-1/user-1',
|
|
ratingKey: 'unwatched',
|
|
actionType: OfflineActionType.unwatched.id,
|
|
);
|
|
|
|
final rows = await db.getPendingWatchActions(profileId: 'local-1');
|
|
for (var index = 0; index < rows.length; index++) {
|
|
await (db.update(db.offlineWatchProgress)..where((t) => t.id.equals(rows[index].id))).write(
|
|
OfflineWatchProgressCompanion(createdAt: Value(3000 + index), updatedAt: Value(4000 + index)),
|
|
);
|
|
}
|
|
await db.updateSyncAttempt(rows.first.id, 'retry-without-protected-payload');
|
|
await db.updateSyncAttempt(rows.first.id, 'retry-without-protected-payload');
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late Directory tempDir;
|
|
late File databaseFile;
|
|
late SharedPreferencesWithCache prefs;
|
|
AppDatabase? database;
|
|
|
|
Future<AppDatabaseBootstrap> open({
|
|
bool isTvos = true,
|
|
TvosDatabaseRecoveryStore? store,
|
|
TvosDatabaseRecoveryPriorInstallEvidence? priorEvidence,
|
|
}) async {
|
|
final result = await AppDatabase.open(
|
|
isTvos: isTvos,
|
|
databaseFile: databaseFile,
|
|
preferences: prefs,
|
|
recoveryStore: store,
|
|
priorInstallEvidence: priorEvidence,
|
|
);
|
|
database = result.database;
|
|
return result;
|
|
}
|
|
|
|
Future<void> closeAndDelete() async {
|
|
await database?.close();
|
|
database = null;
|
|
await _deleteDatabase(databaseFile);
|
|
}
|
|
|
|
setUp(() async {
|
|
resetSharedPreferencesForTest();
|
|
tempDir = await Directory.systemTemp.createTemp('plezy_tvos_recovery_');
|
|
databaseFile = File('${tempDir.path}/plezy_downloads.db');
|
|
prefs = await BaseSharedPreferencesService.sharedCache();
|
|
});
|
|
|
|
tearDown(() async {
|
|
await database?.close();
|
|
await tempDir.delete(recursive: true);
|
|
});
|
|
|
|
test('restores exact four critical groups and excludes reconstructible tables', () async {
|
|
final first = await open();
|
|
expect(first.recoveryOutcome, TvosDatabaseRecoveryOutcome.fresh);
|
|
await _seedCriticalRows(first.database);
|
|
|
|
await first.database.into(first.database.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'cache', data: '{}'));
|
|
await first.database
|
|
.into(first.database.downloadedMedia)
|
|
.insert(
|
|
DownloadedMediaCompanion.insert(
|
|
serverId: 'server-1',
|
|
ratingKey: 'download',
|
|
globalKey: 'server-1:download',
|
|
type: 'movie',
|
|
status: DownloadStatus.completed.index,
|
|
),
|
|
);
|
|
await first.database.addDownloadOwner(profileId: 'local-1', globalKey: 'server-1:download');
|
|
await first.database
|
|
.into(first.database.downloadQueue)
|
|
.insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'server-1:download', addedAt: 5000));
|
|
await first.database.insertSyncRule(
|
|
profileId: 'local-1',
|
|
serverId: ServerId('server-1'),
|
|
ratingKey: 'rule',
|
|
globalKey: 'server-1:rule',
|
|
targetType: 'movie',
|
|
episodeCount: 1,
|
|
);
|
|
|
|
final expected = await _criticalRows(first.database);
|
|
await closeAndDelete();
|
|
|
|
final restored = await open();
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await _criticalRows(restored.database), expected);
|
|
expect(await restored.database.select(restored.database.apiCache).get(), isEmpty);
|
|
expect(await restored.database.select(restored.database.downloadedMedia).get(), isEmpty);
|
|
expect(await restored.database.select(restored.database.downloadOwners).get(), isEmpty);
|
|
expect(await restored.database.select(restored.database.downloadQueue).get(), isEmpty);
|
|
expect(await restored.database.select(restored.database.syncRules).get(), isEmpty);
|
|
});
|
|
|
|
test('legacy plaintext committed image is restored and replaced with protected credentials', () async {
|
|
CredentialVault.resetKeyForTesting();
|
|
const connectionToken = 'legacy-plaintext-connection-token';
|
|
const profileToken = 'legacy-plaintext-profile-token';
|
|
final legacyConfig = jsonEncode({
|
|
'baseUrl': 'https://legacy.invalid',
|
|
'accessToken': connectionToken,
|
|
'userId': 'legacy-user',
|
|
});
|
|
final identityPayload = jsonEncode({
|
|
'version': TvosDatabaseRecoveryStore.recoveryFormatVersion,
|
|
'rows': {
|
|
'connections': [
|
|
{
|
|
'id': 'legacy-jellyfin',
|
|
'kind': 'jellyfin',
|
|
'displayName': 'Legacy Jellyfin',
|
|
'configJson': legacyConfig,
|
|
'isDefault': true,
|
|
'createdAt': 1000,
|
|
'lastAuthenticatedAt': null,
|
|
},
|
|
],
|
|
'profiles': [
|
|
{
|
|
'id': 'legacy-profile',
|
|
'kind': 'local',
|
|
'displayName': 'Legacy Profile',
|
|
'avatarThumbUrl': null,
|
|
'configJson': '{}',
|
|
'sortOrder': 0,
|
|
'createdAt': 2000,
|
|
'lastUsedAt': null,
|
|
},
|
|
],
|
|
'profileConnections': [
|
|
{
|
|
'profileId': 'legacy-profile',
|
|
'connectionId': 'legacy-jellyfin',
|
|
'userToken': profileToken,
|
|
'userIdentifier': 'legacy-user',
|
|
'isDefault': true,
|
|
'tokenAcquiredAt': null,
|
|
'lastUsedAt': null,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
final pendingPayload = jsonEncode({
|
|
'version': TvosDatabaseRecoveryStore.recoveryFormatVersion,
|
|
'rows': {'offlineWatchProgress': <Object?>[]},
|
|
});
|
|
final manifest = jsonEncode({
|
|
'version': TvosDatabaseRecoveryStore.recoveryFormatVersion,
|
|
'state': 'committed',
|
|
'identityDigest': sha256.convert(utf8.encode(identityPayload)).toString(),
|
|
'pendingDigest': sha256.convert(utf8.encode(pendingPayload)).toString(),
|
|
});
|
|
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identityPayload);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pendingPayload);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest);
|
|
|
|
final restored = await open();
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
|
|
final connectionRow = await restored.database.select(restored.database.connections).getSingle();
|
|
final restoredConfig = jsonDecode(connectionRow.configJson) as Map<String, dynamic>;
|
|
final protectedConnectionToken = restoredConfig['accessToken'] as String;
|
|
expect(CredentialVault.isProtected(protectedConnectionToken), isTrue);
|
|
expect(await CredentialVault.reveal(protectedConnectionToken), connectionToken);
|
|
final joinRow = await restored.database.select(restored.database.profileConnections).getSingle();
|
|
expect(CredentialVault.isProtected(joinRow.userToken), isTrue);
|
|
expect(await CredentialVault.reveal(joinRow.userToken), profileToken);
|
|
|
|
final replacementIdentity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!;
|
|
expect(replacementIdentity, isNot(contains(connectionToken)));
|
|
expect(replacementIdentity, isNot(contains(profileToken)));
|
|
final replacementRows = (jsonDecode(replacementIdentity) as Map<String, dynamic>)['rows'] as Map<String, dynamic>;
|
|
final replacementConnection = (replacementRows['connections'] as List).single as Map<String, dynamic>;
|
|
final replacementConfig = jsonDecode(replacementConnection['configJson'] as String) as Map<String, dynamic>;
|
|
expect(await CredentialVault.reveal(replacementConfig['accessToken'] as String), connectionToken);
|
|
final replacementJoin = (replacementRows['profileConnections'] as List).single as Map<String, dynamic>;
|
|
expect(await CredentialVault.reveal(replacementJoin['userToken'] as String), profileToken);
|
|
|
|
final replacementManifest =
|
|
jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
|
|
expect(replacementManifest['state'], 'committed');
|
|
expect(replacementManifest['identityDigest'], sha256.convert(utf8.encode(replacementIdentity)).toString());
|
|
});
|
|
|
|
test('rejects unreleased generation-bearing recovery payloads', () async {
|
|
final first = await open();
|
|
await ProfileRegistry(first.database).upsert(_profile('unknown-generation'));
|
|
await first.database.close();
|
|
database = null;
|
|
|
|
final identity = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.identityKey)!) as Map<String, dynamic>;
|
|
final pending = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!) as Map<String, dynamic>;
|
|
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
|
|
identity['generation'] = 7;
|
|
pending['generation'] = 11;
|
|
final legacyIdentity = jsonEncode(identity);
|
|
final legacyPending = jsonEncode(pending);
|
|
manifest
|
|
..['identityGeneration'] = 7
|
|
..['identityDigest'] = sha256.convert(utf8.encode(legacyIdentity)).toString()
|
|
..['pendingGeneration'] = 11
|
|
..['pendingDigest'] = sha256.convert(utf8.encode(legacyPending)).toString();
|
|
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, legacyIdentity);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, legacyPending);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
|
|
await _deleteDatabase(databaseFile);
|
|
|
|
final rejected = await open();
|
|
expect(rejected.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect(await _criticalRows(rejected.database), everyElement(isEmpty));
|
|
});
|
|
|
|
for (final failurePhase in ['mutation', 'row read']) {
|
|
test('$failurePhase failure does not poison later durable mutations', () async {
|
|
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
|
|
final first = await open(store: store);
|
|
final originalError = StateError('injected $failurePhase failure');
|
|
|
|
await expectLater(
|
|
store.runDurableMutation<void>(
|
|
group: TvosDatabaseRecoveryGroup.identity,
|
|
mutation: () async {
|
|
if (failurePhase == 'mutation') throw originalError;
|
|
},
|
|
readIdentity: () async {
|
|
if (failurePhase == 'row read') throw originalError;
|
|
return const {'connections': <Object?>[], 'profiles': <Object?>[], 'profileConnections': <Object?>[]};
|
|
},
|
|
readPending: () async => const {'offlineWatchProgress': <Object?>[]},
|
|
),
|
|
throwsA(same(originalError)),
|
|
);
|
|
|
|
await ProfileRegistry(first.database).upsert(_profile('after-$failurePhase'));
|
|
expect(await ProfileRegistry(first.database).get('after-$failurePhase'), isNotNull);
|
|
});
|
|
}
|
|
|
|
test('failed manifest invalidation reloads durable state and retries before mutation', () async {
|
|
await database?.close();
|
|
database = null;
|
|
resetSharedPreferencesForTest();
|
|
final preferencesPlatform = _FailingStringPreferencesPlatform(SharedPreferencesAsyncPlatform.instance!);
|
|
SharedPreferencesAsyncPlatform.instance = preferencesPlatform;
|
|
addTearDown(() => SharedPreferencesAsyncPlatform.instance = preferencesPlatform.delegate);
|
|
prefs = await BaseSharedPreferencesService.sharedCache();
|
|
|
|
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
|
|
await open(store: store);
|
|
final durableManifest = await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey);
|
|
expect(durableManifest, contains('committed'));
|
|
|
|
var mutationCount = 0;
|
|
Future<void> mutate() => store.runDurableMutation<void>(
|
|
group: TvosDatabaseRecoveryGroup.identity,
|
|
mutation: () async {
|
|
mutationCount++;
|
|
},
|
|
readIdentity: () async => const {
|
|
'connections': <Object?>[],
|
|
'profiles': <Object?>[],
|
|
'profileConnections': <Object?>[],
|
|
},
|
|
readPending: () async => const {'offlineWatchProgress': <Object?>[]},
|
|
);
|
|
|
|
preferencesPlatform.failNextKey = TvosDatabaseRecoveryStore.manifestKey;
|
|
await expectLater(mutate(), throwsA(isA<TvosDatabaseDurabilityException>()));
|
|
expect(mutationCount, 0, reason: 'the authoritative mutation must wait for durable invalidation');
|
|
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), durableManifest);
|
|
expect(await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey), durableManifest);
|
|
|
|
final attemptsAfterFailure = preferencesPlatform.stringWriteAttempts[TvosDatabaseRecoveryStore.manifestKey]!;
|
|
await mutate();
|
|
expect(mutationCount, 1);
|
|
expect(
|
|
preferencesPlatform.stringWriteAttempts[TvosDatabaseRecoveryStore.manifestKey],
|
|
greaterThan(attemptsAfterFailure),
|
|
);
|
|
expect(await preferencesPlatform.persistedString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
|
|
});
|
|
|
|
test('unrelated preferences do not consume the recovery image budget', () async {
|
|
final first = await open();
|
|
await ProfileRegistry(first.database).upsert(_profile('survives-large-settings'));
|
|
await first.database.close();
|
|
database = null;
|
|
await prefs.setString('unrelated_large_setting', List.filled(500000, 'x').join());
|
|
await _deleteDatabase(databaseFile);
|
|
|
|
final restored = await open();
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await ProfileRegistry(restored.database).get('survives-large-settings'), isNotNull);
|
|
});
|
|
|
|
group('startup classification', () {
|
|
test('non-tvOS is notApplicable and writes no recovery keys', () async {
|
|
final result = await open(isTvos: false);
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable);
|
|
expect(prefs.keys.where((key) => key.startsWith(TvosDatabaseRecoveryStore.keyPrefix)), isEmpty);
|
|
});
|
|
|
|
test('missing database without marker or evidence is fresh', () async {
|
|
final result = await open();
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.fresh);
|
|
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
|
|
});
|
|
|
|
test('missing database with prior-install evidence requires recovery', () async {
|
|
await prefs.setString('active_app_profile_id', 'surviving-profile');
|
|
final result = await open();
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty);
|
|
});
|
|
|
|
test('recovery-required marker gates a materialized empty database until acknowledgement', () async {
|
|
await prefs.setString('active_app_profile_id', 'surviving-profile');
|
|
|
|
final missing = await open();
|
|
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
|
|
await missing.database.close();
|
|
database = null;
|
|
|
|
final restarted = await open();
|
|
expect(restarted.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
|
|
await expectLater(
|
|
ProfileRegistry(restarted.database).upsert(_profile('blocked-after-restart')),
|
|
throwsA(isA<TvosDatabaseDurabilityException>()),
|
|
);
|
|
expect(await ProfileRegistry(restarted.database).get('blocked-after-restart'), isNull);
|
|
|
|
await restarted.database.acknowledgeTvosDatabaseRecoveryRequired();
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
|
|
await ProfileRegistry(restarted.database).upsert(_profile('acknowledged'));
|
|
await closeAndDelete();
|
|
|
|
final restored = await open();
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await ProfileRegistry(restored.database).get('acknowledged'), isNotNull);
|
|
});
|
|
|
|
test('successful restore clears recovery-required gate on a materialized candidate', () async {
|
|
final first = await open();
|
|
await ProfileRegistry(first.database).upsert(_profile('restored-after-restart'));
|
|
await first.database.close();
|
|
database = null;
|
|
final identity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!;
|
|
final pending = prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!;
|
|
final manifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!;
|
|
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, '$identity-corrupt');
|
|
await _deleteDatabase(databaseFile);
|
|
|
|
final missing = await open();
|
|
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
|
|
expect(await ProfileRegistry(missing.database).get('restored-after-restart'), isNull);
|
|
await missing.database.close();
|
|
database = null;
|
|
|
|
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identity);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pending);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest);
|
|
final restarted = await open();
|
|
|
|
expect(restarted.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
|
|
expect(await ProfileRegistry(restarted.database).get('restored-after-restart'), isNotNull);
|
|
});
|
|
|
|
test('failed marker removal replays a successful restore idempotently on restart', () async {
|
|
final first = await open();
|
|
await _seedCriticalRows(first.database);
|
|
final expected = await _criticalRows(first.database);
|
|
await first.database.close();
|
|
database = null;
|
|
|
|
final identity = prefs.getString(TvosDatabaseRecoveryStore.identityKey)!;
|
|
final pending = prefs.getString(TvosDatabaseRecoveryStore.pendingKey)!;
|
|
final manifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!;
|
|
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, '$identity-corrupt');
|
|
await _deleteDatabase(databaseFile);
|
|
|
|
final missing = await open();
|
|
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
|
|
await missing.database.close();
|
|
database = null;
|
|
|
|
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, identity);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.pendingKey, pending);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, manifest);
|
|
var markerRemovalAttempts = 0;
|
|
final failingStore = TvosDatabaseRecoveryStore(
|
|
prefs,
|
|
isTvos: true,
|
|
debugBeforePreferenceWrite: (key) async {
|
|
if (key == TvosDatabaseRecoveryStore.recoveryRequiredKey) {
|
|
markerRemovalAttempts++;
|
|
throw StateError('marker removal failed');
|
|
}
|
|
},
|
|
);
|
|
|
|
final restoredButUnmarked = await open(store: failingStore);
|
|
expect(restoredButUnmarked.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect(markerRemovalAttempts, 1);
|
|
expect(await _criticalRows(restoredButUnmarked.database), expected);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue);
|
|
await restoredButUnmarked.database.close();
|
|
database = null;
|
|
|
|
final replayed = await open();
|
|
expect(replayed.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await _criticalRows(replayed.database), expected);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
|
|
await replayed.database.close();
|
|
database = null;
|
|
|
|
final converged = await open();
|
|
expect(converged.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
expect(await _criticalRows(converged.database), expected);
|
|
expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull);
|
|
});
|
|
|
|
test('existing database without marker is adopted', () async {
|
|
final raw = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
|
await raw.select(raw.connections).get();
|
|
await raw.close();
|
|
|
|
final result = await open();
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
|
|
});
|
|
|
|
test('existing readable database is adopted when its recovery image cannot be serialized', () async {
|
|
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
|
|
|
|
final outcome = await store.reconcile(
|
|
databaseExisted: true,
|
|
readIdentity: () async => {
|
|
'connections': [
|
|
{'unsupported': Object()},
|
|
],
|
|
'profiles': <Object?>[],
|
|
'profileConnections': <Object?>[],
|
|
},
|
|
readPending: () async => const {'offlineWatchProgress': <Object?>[]},
|
|
restore: (_) async => fail('an existing database must not be restored'),
|
|
hasPriorInstallEvidence: () async => false,
|
|
);
|
|
|
|
expect(outcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
var mutationRan = false;
|
|
await store.runDurableMutation<void>(
|
|
group: TvosDatabaseRecoveryGroup.identity,
|
|
mutation: () async => mutationRan = true,
|
|
readIdentity: () async => throw StateError('recovery was not disabled'),
|
|
readPending: () async => throw StateError('recovery was not disabled'),
|
|
);
|
|
expect(mutationRan, isTrue);
|
|
});
|
|
|
|
for (final failingGroup in ['identity', 'pending']) {
|
|
test('existing database $failingGroup row reader failure propagates', () async {
|
|
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true);
|
|
final readError = StateError('$failingGroup database read failed');
|
|
|
|
await expectLater(
|
|
store.reconcile(
|
|
databaseExisted: true,
|
|
readIdentity: () async {
|
|
if (failingGroup == 'identity') throw readError;
|
|
return const {'connections': <Object?>[], 'profiles': <Object?>[], 'profileConnections': <Object?>[]};
|
|
},
|
|
readPending: () async {
|
|
if (failingGroup == 'pending') throw readError;
|
|
return const {'offlineWatchProgress': <Object?>[]};
|
|
},
|
|
restore: (_) async => fail('an existing database must not be restored'),
|
|
hasPriorInstallEvidence: () async => false,
|
|
),
|
|
throwsA(same(readError)),
|
|
);
|
|
expect(prefs.keys.where((key) => key.startsWith(TvosDatabaseRecoveryStore.keyPrefix)), isEmpty);
|
|
});
|
|
}
|
|
|
|
test('existing database remains authoritative when recovery preferences cannot be written', () async {
|
|
final raw = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
|
await raw.select(raw.connections).get();
|
|
await raw.close();
|
|
final store = TvosDatabaseRecoveryStore(
|
|
prefs,
|
|
isTvos: true,
|
|
debugBeforePreferenceWrite: (_) async => throw StateError('write failed'),
|
|
);
|
|
|
|
final result = await open(store: store);
|
|
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
await ProfileRegistry(result.database).upsert(_profile('database-remains-authoritative'));
|
|
expect(await ProfileRegistry(result.database).get('database-remains-authoritative'), isNotNull);
|
|
});
|
|
|
|
test('existing database remains authoritative when recovery image exceeds its budget', () async {
|
|
final raw = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
|
await raw.select(raw.connections).get();
|
|
await raw.close();
|
|
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1);
|
|
|
|
final result = await open(store: store);
|
|
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
await ProfileRegistry(result.database).upsert(_profile('database-survives-recovery-budget'));
|
|
expect(await ProfileRegistry(result.database).get('database-survives-recovery-budget'), isNotNull);
|
|
});
|
|
|
|
test('startup invalidation failure blocks identity mutation until durable retry', () async {
|
|
final first = await open();
|
|
await ProfileRegistry(first.database).upsert(_profile('stale-image'));
|
|
final staleManifest = prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!;
|
|
await first.database.close();
|
|
database = null;
|
|
|
|
var failManifestWrites = true;
|
|
var failedInvalidations = 0;
|
|
final store = TvosDatabaseRecoveryStore(
|
|
prefs,
|
|
isTvos: true,
|
|
debugBeforePreferenceWrite: (key) async {
|
|
if (failManifestWrites && key == TvosDatabaseRecoveryStore.manifestKey) {
|
|
failedInvalidations++;
|
|
throw StateError('manifest invalidation failed');
|
|
}
|
|
},
|
|
);
|
|
final existing = await open(store: store);
|
|
expect(existing.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), staleManifest);
|
|
|
|
await expectLater(
|
|
ProfileRegistry(existing.database).upsert(_profile('blocked-by-stale-image')),
|
|
throwsA(isA<TvosDatabaseDurabilityException>()),
|
|
);
|
|
expect(failedInvalidations, 2);
|
|
expect(await ProfileRegistry(existing.database).get('blocked-by-stale-image'), isNull);
|
|
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), staleManifest);
|
|
await existing.database.close();
|
|
database = null;
|
|
|
|
final probeFile = File('${tempDir.path}/stale-image-probe.db');
|
|
final staleRestore = await AppDatabase.open(isTvos: true, databaseFile: probeFile, preferences: prefs);
|
|
expect(staleRestore.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await ProfileRegistry(staleRestore.database).get('stale-image'), isNotNull);
|
|
expect(await ProfileRegistry(staleRestore.database).get('blocked-by-stale-image'), isNull);
|
|
await staleRestore.database.close();
|
|
await _deleteDatabase(probeFile);
|
|
|
|
failManifestWrites = false;
|
|
final retried = await open(store: store);
|
|
expect(retried.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
await ProfileRegistry(retried.database).upsert(_profile('committed-after-retry'));
|
|
expect(await ProfileRegistry(retried.database).get('committed-after-retry'), isNotNull);
|
|
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), isNot(staleManifest));
|
|
await retried.database.close();
|
|
database = null;
|
|
await _deleteDatabase(databaseFile);
|
|
|
|
final recovered = await open();
|
|
expect(recovered.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await ProfileRegistry(recovered.database).get('stale-image'), isNotNull);
|
|
expect(await ProfileRegistry(recovered.database).get('committed-after-retry'), isNotNull);
|
|
expect(await ProfileRegistry(recovered.database).get('blocked-by-stale-image'), isNull);
|
|
});
|
|
|
|
test('existing database repairs an interrupted manifest authoritatively', () async {
|
|
final first = await open();
|
|
await ProfileRegistry(first.database).upsert(_profile('authoritative'));
|
|
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
|
|
manifest['state'] = 'invalidated';
|
|
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
|
|
await first.database.close();
|
|
database = null;
|
|
|
|
final repaired = await open();
|
|
expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
expect(await ProfileRegistry(repaired.database).get('authoritative'), isNotNull);
|
|
expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed'));
|
|
await closeAndDelete();
|
|
|
|
final restored = await open();
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await ProfileRegistry(restored.database).get('authoritative'), isNotNull);
|
|
});
|
|
});
|
|
|
|
group('invalid missing-database recovery images', () {
|
|
Future<void> expectRejected(Future<void> Function() corrupt) async {
|
|
final first = await open();
|
|
await ProfileRegistry(first.database).upsert(_profile('must-not-partially-restore'));
|
|
await first.database.close();
|
|
database = null;
|
|
await corrupt();
|
|
await _deleteDatabase(databaseFile);
|
|
|
|
final result = await open();
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty);
|
|
}
|
|
|
|
test('absent payload', () => expectRejected(() => prefs.remove(TvosDatabaseRecoveryStore.identityKey)));
|
|
|
|
test('corrupt manifest', () => expectRejected(() => prefs.setString(TvosDatabaseRecoveryStore.manifestKey, '{')));
|
|
|
|
test('unsupported version', () async {
|
|
await expectRejected(() async {
|
|
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
|
|
manifest['version'] = 99;
|
|
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
|
|
});
|
|
});
|
|
|
|
test('digest mismatch', () async {
|
|
await expectRejected(() async {
|
|
await prefs.setString(
|
|
TvosDatabaseRecoveryStore.identityKey,
|
|
'${prefs.getString(TvosDatabaseRecoveryStore.identityKey)} ',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('structurally invalid payload with matching digest', () async {
|
|
await expectRejected(() async {
|
|
final payload = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.identityKey)!) as Map<String, dynamic>;
|
|
(payload['rows'] as Map<String, dynamic>).remove('profiles');
|
|
final encoded = jsonEncode(payload);
|
|
final manifest = jsonDecode(prefs.getString(TvosDatabaseRecoveryStore.manifestKey)!) as Map<String, dynamic>;
|
|
manifest['identityDigest'] = sha256.convert(utf8.encode(encoded)).toString();
|
|
await prefs.setString(TvosDatabaseRecoveryStore.identityKey, encoded);
|
|
await prefs.setString(TvosDatabaseRecoveryStore.manifestKey, jsonEncode(manifest));
|
|
});
|
|
});
|
|
|
|
test('over-budget complete preference image', () async {
|
|
final first = await open();
|
|
await first.database.close();
|
|
database = null;
|
|
await _deleteDatabase(databaseFile);
|
|
final tinyStore = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1);
|
|
final result = await open(store: tinyStore);
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect((await _criticalRows(result.database)).expand((rows) => rows), isEmpty);
|
|
});
|
|
});
|
|
|
|
test('explicit sign-in acknowledgement replaces invalid evidence before new mutations', () async {
|
|
final first = await open();
|
|
await first.database.close();
|
|
database = null;
|
|
await prefs.setString(
|
|
TvosDatabaseRecoveryStore.identityKey,
|
|
'${prefs.getString(TvosDatabaseRecoveryStore.identityKey)}-digest-mismatch',
|
|
);
|
|
await _deleteDatabase(databaseFile);
|
|
|
|
var result = await open();
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
await expectLater(
|
|
ConnectionRegistry(result.database).upsert(_connection('blocked-before-acknowledgement')),
|
|
throwsA(isA<TvosDatabaseDurabilityException>()),
|
|
);
|
|
expect(await result.database.select(result.database.connections).get(), isEmpty);
|
|
|
|
await result.database.acknowledgeTvosDatabaseRecoveryRequired();
|
|
await ConnectionRegistry(result.database).upsert(_connection('new-sign-in'));
|
|
final expected = await _criticalRows(result.database);
|
|
await closeAndDelete();
|
|
|
|
result = await open();
|
|
expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await _criticalRows(result.database), expected);
|
|
});
|
|
|
|
group('two-phase crash protocol', () {
|
|
for (final point in TvosDatabaseRecoveryCrashPoint.values) {
|
|
test('$point converges from authoritative database', () async {
|
|
var armed = false;
|
|
final crashStore = TvosDatabaseRecoveryStore(
|
|
prefs,
|
|
isTvos: true,
|
|
debugCrash: (candidate) async {
|
|
if (armed && candidate == point) throw StateError('simulated process stop');
|
|
},
|
|
);
|
|
final first = await open(store: crashStore);
|
|
armed = true;
|
|
await expectLater(ProfileRegistry(first.database).upsert(_profile('crash-row')), throwsA(anything));
|
|
armed = false;
|
|
final expected = await _criticalRows(first.database);
|
|
await first.database.close();
|
|
database = null;
|
|
|
|
final repaired = await open();
|
|
expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
expect(await _criticalRows(repaired.database), expected);
|
|
await closeAndDelete();
|
|
|
|
final restored = await open();
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await _criticalRows(restored.database), expected);
|
|
});
|
|
}
|
|
|
|
for (final point in const [
|
|
TvosDatabaseRecoveryCrashPoint.afterInvalidation,
|
|
TvosDatabaseRecoveryCrashPoint.afterDatabaseMutation,
|
|
TvosDatabaseRecoveryCrashPoint.afterPayloadWrite,
|
|
]) {
|
|
test('$point never restores stale state when database is missing', () async {
|
|
var armed = false;
|
|
final crashStore = TvosDatabaseRecoveryStore(
|
|
prefs,
|
|
isTvos: true,
|
|
debugCrash: (candidate) async {
|
|
if (armed && candidate == point) throw StateError('simulated process stop');
|
|
},
|
|
);
|
|
final first = await open(store: crashStore);
|
|
armed = true;
|
|
await expectLater(ProfileRegistry(first.database).upsert(_profile('uncommitted')), throwsA(anything));
|
|
await closeAndDelete();
|
|
|
|
final missing = await open();
|
|
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect((await _criticalRows(missing.database)).expand((rows) => rows), isEmpty);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('preference-write failure is typed, payload-free, and later converges', () async {
|
|
var armed = false;
|
|
final store = TvosDatabaseRecoveryStore(
|
|
prefs,
|
|
isTvos: true,
|
|
debugBeforePreferenceWrite: (key) async {
|
|
if (armed && key == TvosDatabaseRecoveryStore.identityKey) throw StateError('write failed');
|
|
},
|
|
);
|
|
final first = await open(store: store);
|
|
armed = true;
|
|
Object? failure;
|
|
try {
|
|
await ConnectionRegistry(first.database).upsert(_connection('write-failure-canary'));
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
expect(failure, isA<TvosDatabaseDurabilityException>());
|
|
expect(failure.toString(), isNot(contains('protected-connection-canary')));
|
|
expect(await first.database.select(first.database.connections).get(), hasLength(1));
|
|
armed = false;
|
|
await first.database.close();
|
|
database = null;
|
|
|
|
final repaired = await open();
|
|
expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase);
|
|
await closeAndDelete();
|
|
final restored = await open();
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await restored.database.select(restored.database.connections).get(), hasLength(1));
|
|
});
|
|
|
|
test('oversized identity degrades recovery without rejecting authoritative mutations', () async {
|
|
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1200);
|
|
final first = await open(store: store);
|
|
final canary = List.filled(3000, 'PROTECTED-SIZE-CANARY').join();
|
|
final huge = JellyfinConnection(
|
|
id: 'huge',
|
|
baseUrl: 'https://media.invalid',
|
|
serverName: canary,
|
|
serverMachineId: 'huge',
|
|
userId: 'user',
|
|
userName: 'user',
|
|
accessToken: canary,
|
|
deviceId: 'device',
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(1),
|
|
);
|
|
|
|
await ConnectionRegistry(first.database).upsert(huge);
|
|
await ProfileRegistry(first.database).upsert(_profile('after-degradation'));
|
|
expect(await first.database.select(first.database.connections).get(), hasLength(1));
|
|
expect(await first.database.select(first.database.profiles).get(), hasLength(1));
|
|
|
|
await closeAndDelete();
|
|
final missing = await open(store: TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: 1200));
|
|
expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired);
|
|
expect(await missing.database.select(missing.database.connections).get(), isEmpty);
|
|
expect(await missing.database.select(missing.database.profiles).get(), isEmpty);
|
|
});
|
|
|
|
test('oversized pending actions retain identity in a bounded recovery image', () async {
|
|
const ceiling = 5000;
|
|
final store = TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: ceiling);
|
|
final first = await open(store: store);
|
|
await ConnectionRegistry(first.database).upsert(_connection('retained'));
|
|
|
|
for (var index = 0; index < 20; index++) {
|
|
await first.database.insertWatchAction(
|
|
profileId: 'profile',
|
|
serverId: ServerId('server'),
|
|
ratingKey: '$index-${List.filled(500, 'pending').join()}',
|
|
actionType: OfflineActionType.watched.id,
|
|
);
|
|
}
|
|
expect(await first.database.getPendingWatchActions(), hasLength(20));
|
|
|
|
await closeAndDelete();
|
|
final restored = await open(
|
|
store: TvosDatabaseRecoveryStore(prefs, isTvos: true, preferenceImageByteCeiling: ceiling),
|
|
);
|
|
expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored);
|
|
expect(await restored.database.select(restored.database.connections).get(), hasLength(1));
|
|
expect(await restored.database.getPendingWatchActions(), isEmpty);
|
|
});
|
|
|
|
test('pending mutation wrappers preserve updates, deletes, profile teardown, and clears', () async {
|
|
var result = await open();
|
|
await result.database.upsertProgressAction(
|
|
profileId: 'p1',
|
|
serverId: ServerId('s1'),
|
|
ratingKey: 'a',
|
|
viewOffset: 10,
|
|
duration: 100,
|
|
shouldMarkWatched: false,
|
|
);
|
|
await result.database.adoptLegacyOfflineWatchActionsForProfile('p1');
|
|
await result.database.insertWatchAction(
|
|
profileId: 'p1',
|
|
serverId: ServerId('s1'),
|
|
ratingKey: 'b',
|
|
actionType: OfflineActionType.watched.id,
|
|
);
|
|
final rows = await result.database.getPendingWatchActions();
|
|
await result.database.updateSyncAttempt(rows.first.id, 'retry');
|
|
await result.database.deleteWatchAction(rows.last.id);
|
|
await result.database.insertWatchAction(
|
|
profileId: 'p2',
|
|
serverId: ServerId('s1'),
|
|
ratingKey: 'c',
|
|
actionType: OfflineActionType.unwatched.id,
|
|
);
|
|
await result.database.deleteWatchActionsForProfile('p1');
|
|
var expected = await _criticalRows(result.database);
|
|
await closeAndDelete();
|
|
result = await open();
|
|
expect(await _criticalRows(result.database), expected);
|
|
|
|
await result.database.clearAllWatchActions();
|
|
expected = await _criticalRows(result.database);
|
|
await closeAndDelete();
|
|
result = await open();
|
|
expect(await _criticalRows(result.database), expected);
|
|
expect(await result.database.getPendingWatchActions(), isEmpty);
|
|
});
|
|
|
|
test('identity mutation wrappers preserve defaults, tokens, cascades, teardown, and clears', () async {
|
|
var result = await open();
|
|
var connections = ConnectionRegistry(result.database);
|
|
var profiles = ProfileRegistry(result.database);
|
|
var joins = ProfileConnectionRegistry(result.database);
|
|
|
|
await connections.upsert(_connection('c1'));
|
|
await connections.upsert(_connection('c2'));
|
|
await connections.setDefault('c2');
|
|
await connections.recordAuthSuccess('c2', DateTime.fromMillisecondsSinceEpoch(9000));
|
|
await profiles.upsert(_profile('p1'));
|
|
await profiles.upsert(_profile('p2'));
|
|
await profiles.markUsed('p1', DateTime.fromMillisecondsSinceEpoch(9100));
|
|
await profiles.upsert(
|
|
Profile.plexHome(
|
|
id: 'legacy-home',
|
|
displayName: 'Legacy',
|
|
parentConnectionId: 'c1',
|
|
createdAt: DateTime.fromMillisecondsSinceEpoch(1),
|
|
),
|
|
);
|
|
await profiles.dropAllPlexHomeRows();
|
|
await joins.upsert(
|
|
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 'token-1', userIdentifier: 'u1'),
|
|
);
|
|
await joins.upsert(
|
|
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 'token-2', userIdentifier: 'u2'),
|
|
);
|
|
await joins.recordToken('p1', 'c1', 'token-refreshed');
|
|
final protectedToken = await CredentialVault.protect('token-clear-canary');
|
|
final protectedEnvelope = jsonDecode(protectedToken.substring('enc:v1:'.length)) as Map<String, dynamic>;
|
|
final ciphertext = protectedEnvelope['c'] as String;
|
|
protectedEnvelope['c'] = '${ciphertext.startsWith('A') ? 'B' : 'A'}${ciphertext.substring(1)}';
|
|
final corruptedToken = 'enc:v1:${jsonEncode(protectedEnvelope)}';
|
|
await (result.database.update(
|
|
result.database.profileConnections,
|
|
)..where((t) => t.connectionId.equals('c1'))).write(ProfileConnectionsCompanion(userToken: Value(corruptedToken)));
|
|
await joins.get('p1', 'c1');
|
|
final clearedTokenRow = await (result.database.select(
|
|
result.database.profileConnections,
|
|
)..where((t) => t.connectionId.equals('c1'))).getSingle();
|
|
expect(clearedTokenRow.userToken, isEmpty);
|
|
await joins.markUsed('p1', 'c1');
|
|
await joins.setDefault('p1', 'c2');
|
|
await joins.remove('p1', 'c2');
|
|
await joins.promoteMissingDefaults();
|
|
await joins.removeAllForConnection('c2');
|
|
await connections.remove('c2');
|
|
await profiles.remove('p2');
|
|
|
|
var expected = await _criticalRows(result.database);
|
|
await closeAndDelete();
|
|
result = await open();
|
|
expect(await _criticalRows(result.database), expected);
|
|
|
|
connections = ConnectionRegistry(result.database);
|
|
profiles = ProfileRegistry(result.database);
|
|
joins = ProfileConnectionRegistry(result.database);
|
|
await joins.clear();
|
|
await profiles.clear();
|
|
await connections.clear();
|
|
expected = await _criticalRows(result.database);
|
|
await closeAndDelete();
|
|
result = await open();
|
|
expect(await _criticalRows(result.database), expected);
|
|
expect(expected.take(3).expand((rows) => rows), isEmpty);
|
|
});
|
|
}
|