`File.readAsString` reports a UTF-8 decode failure as a FileSystemException, not a FormatException, so three guards written for that case never ran. The preflight's `on FormatException` branch was unreachable and its `on FileSystemException` sibling waved the document through; the plugin then threw the same FileSystemException, which failed the FormatException/TypeError test that decides repairability; and quarantine's lossy-decode fallback sat dead behind a rethrow. A store with one bad high byte — a UTF-16 BOM, a stray 0x80 — therefore reached the user as a failure screen with no Repair button and no way forward at all. Read bytes and decode explicitly instead, at both sites. Classification moves into describeStoreDamage, so a failure that surfaces after the preflight passed is judged by re-reading the file rather than by the error's type: a denied or locked store is indistinguishable from a decode failure by type or message, and offering a destructive repair for a permissions problem would reset every setting and risk the vault key over something a chmod fixes. isCorruptStoreError went with it, having no remaining callers. A repair that quarantines the store and then cannot reopen it no longer strands the process either. The repaired future was built straight from the cache loader, bypassing the self-healing reset sharedCache installs, so a failed reopen parked a rejected future in _cacheFuture and every later attempt replayed that stale error — with the damaged file already moved aside, so a restart would have booted cleanly. CorruptPreferenceStoreException now carries reopenSafe and a derived, content-free shape: byte length, whether it decoded, whether every byte is zero. #1732 arrived as "FormatException at offset 0" and nothing else, which cannot separate an all-zero file from a non-JSON first character from bytes that are not UTF-8; these can, and never quote the document. Cover the loop against the real desktop backend rather than a fake. shared_preferences_linux is pure Dart, byte-identical to the Windows implementation, and exposes fs/pathProvider, so pointing it at a temp directory exercises the genuine read, parse, cache and write path on any host — the join between preflight, classification and reopen where every one of these defects lived, and which had no coverage at all.
117 lines
4.5 KiB
Dart
117 lines
4.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
|
import 'package:plezy/services/base_shared_preferences_service.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../test_helpers/io_fakes.dart';
|
|
import '../test_helpers/prefs.dart';
|
|
|
|
void main() {
|
|
setUp(resetSharedPreferencesForTest);
|
|
|
|
tearDown(BaseSharedPreferencesService.resetForTesting);
|
|
|
|
test('failed shared cache load is coalesced but a later call retries', () async {
|
|
final firstLoad = Completer<SharedPreferencesWithCache>();
|
|
final originalError = StateError('preferences unavailable');
|
|
final originalStackTrace = StackTrace.current;
|
|
var loadCount = 0;
|
|
|
|
BaseSharedPreferencesService.setCacheLoaderForTesting(() {
|
|
loadCount++;
|
|
if (loadCount == 1) return firstLoad.future;
|
|
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
|
|
});
|
|
|
|
final firstCaller = BaseSharedPreferencesService.sharedCache();
|
|
final concurrentCaller = BaseSharedPreferencesService.sharedCache();
|
|
expect(identical(firstCaller, concurrentCaller), isTrue);
|
|
expect(loadCount, 1);
|
|
|
|
firstLoad.completeError(originalError, originalStackTrace);
|
|
Object? caughtError;
|
|
StackTrace? caughtStackTrace;
|
|
try {
|
|
await firstCaller;
|
|
} catch (error, stackTrace) {
|
|
caughtError = error;
|
|
caughtStackTrace = stackTrace;
|
|
}
|
|
|
|
expect(identical(caughtError, originalError), isTrue);
|
|
expect(caughtStackTrace.toString(), originalStackTrace.toString());
|
|
|
|
final recovered = await BaseSharedPreferencesService.sharedCache();
|
|
expect(loadCount, 2);
|
|
await recovered.setBool('recovered', true);
|
|
expect(recovered.getBool('recovered'), isTrue);
|
|
});
|
|
|
|
test('reset restores the production cache loader', () async {
|
|
BaseSharedPreferencesService.setCacheLoaderForTesting(
|
|
() => Future<SharedPreferencesWithCache>.error(StateError('injected failure')),
|
|
);
|
|
|
|
BaseSharedPreferencesService.resetForTesting();
|
|
|
|
final cache = await BaseSharedPreferencesService.sharedCache();
|
|
await cache.setString('loader', 'production');
|
|
expect(cache.getString('loader'), 'production');
|
|
});
|
|
|
|
_poisonedCacheRegression();
|
|
}
|
|
|
|
/// A repair that quarantines the store and then cannot reopen it must not
|
|
/// leave the process permanently unable to try again.
|
|
///
|
|
/// Before #1732's fix the repaired future was built straight from the cache
|
|
/// loader, bypassing the self-healing `onError` reset that `sharedCache`
|
|
/// installs. A reopen failure therefore parked a rejected future in
|
|
/// `_cacheFuture`, and every later attempt replayed that stale error for the
|
|
/// rest of the process — with the damaged file already moved aside, so a
|
|
/// restart would have booted cleanly.
|
|
void _poisonedCacheRegression() {
|
|
group('repairCorruptStore', () {
|
|
late Directory root;
|
|
late PathProviderPlatform previousPathProvider;
|
|
|
|
setUp(() async {
|
|
resetSharedPreferencesForTest();
|
|
root = await Directory.systemTemp.createTemp('plezy_repair_reopen_');
|
|
previousPathProvider = PathProviderPlatform.instance;
|
|
PathProviderPlatform.instance = FakePathProvider(root);
|
|
});
|
|
|
|
tearDown(() async {
|
|
BaseSharedPreferencesService.resetForTesting();
|
|
PathProviderPlatform.instance = previousPathProvider;
|
|
if (await root.exists()) await root.delete(recursive: true);
|
|
});
|
|
|
|
test('a reopen failure does not poison the shared cache', () async {
|
|
final support = Directory(p.join(root.path, 'support'))..createSync(recursive: true);
|
|
File(p.join(support.path, 'shared_preferences.json')).writeAsStringSync('{"theme":"dark"');
|
|
|
|
var loadCount = 0;
|
|
BaseSharedPreferencesService.setCacheLoaderForTesting(() {
|
|
loadCount++;
|
|
if (loadCount == 1) return Future<SharedPreferencesWithCache>.error(StateError('reopen failed'));
|
|
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
|
|
});
|
|
|
|
await expectLater(BaseSharedPreferencesService.repairCorruptStore(), throwsA(isA<StateError>()));
|
|
|
|
// The damaged file is already quarantined, so the next attempt has a
|
|
// clean slate and must actually be allowed to use it.
|
|
final recovered = await BaseSharedPreferencesService.sharedCache();
|
|
expect(loadCount, 2);
|
|
await recovered.setBool('recovered', true);
|
|
expect(recovered.getBool('recovered'), isTrue);
|
|
});
|
|
});
|
|
}
|