Files
plezy/test/services/base_shared_preferences_service_test.dart
T
edde746 3ae7aa554b fix(prefs): recover a preference store whose bytes are not valid UTF-8
`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.
2026-08-01 06:59:20 +02:00

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);
});
});
}