Files
reelbox/test/library_identity_regression_test.dart

234 lines
8.8 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:shared_preferences/shared_preferences.dart';
const movie = TitleRecord(id: 42, title: 'Movie');
const channel = TitleRecord(id: 42, title: 'Channel', type: 'channel');
class LocalLibraryApi extends ApiService {
@override
Future<void> initialize({bool discover = true}) async {}
}
// Construct the old backup directly: current mutations must never save channels.
String legacyLibrary(AppState state) {
final document = jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
document['saved'] = [channel.toJson(), movie.toJson()];
document['history'] = [
WatchEntry(title: channel, positionSeconds: 30).toJson(),
WatchEntry(title: movie, positionSeconds: 15).toJson(),
];
document['collections'] = [
LocalCollection(
id: 'both',
name: 'Both',
titles: [channel, movie],
).toJson(),
];
return jsonEncode(document);
}
void expectMovieOnly(AppState state) {
expect(state.saved.single.toJson(), movie.toJson());
expect(state.hasSaved(42), isTrue);
expect(state.hasSaved(42, kind: 'channel'), isFalse);
expect(state.history.single.title.toJson(), movie.toJson());
expect(state.resumeSeconds(42), 15);
expect(state.resumeSeconds(42, kind: 'channel'), 0);
expect(state.collections.single.id, 'both');
expect(state.collections.single.name, 'Both');
expect(state.collections.single.titles.single.toJson(), movie.toJson());
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
for (final viaStorage in [false, true]) {
test(
'legacy ${viaStorage ? 'storage migration' : 'import'} prunes channels without losing same-ID movies',
() async {
final store = await SharedPreferences.getInstance();
final state = AppState(api: LocalLibraryApi(), storage: store);
final restored = AppState(api: LocalLibraryApi(), storage: store);
addTearDown(state.dispose);
addTearDown(restored.dispose);
final legacy = legacyLibrary(state);
if (viaStorage) {
await store.setString(AppState.storageKey, legacy);
await state.initialize(discover: false);
} else {
await state.importLibrary(legacy);
}
expectMovieOnly(state);
final sanitized = state.exportLibrary();
expect((jsonDecode(sanitized) as Map)['version'], 1);
expect(store.getString(AppState.storageKey), sanitized);
await restored.initialize(discover: false);
expectMovieOnly(restored);
expect(restored.exportLibrary(), sanitized);
await restored.removeSaved(42, kind: 'channel');
await restored.removeHistory(42, kind: 'channel');
expectMovieOnly(restored);
await restored.removeSaved(42);
await restored.removeHistory(42);
expect(restored.saved, isEmpty);
expect(restored.history, isEmpty);
expect(
restored.collections.single.titles.single.toJson(),
movie.toJson(),
);
},
);
}
test('blocked collection mutations cannot replace a same-ID movie', () async {
final state = AppState(api: LocalLibraryApi());
addTearDown(state.dispose);
final collection = await state.createCollection('Both');
await state.toggleCollectionTitle(collection.id, movie);
final original = state.exportLibrary();
await state.toggleCollectionTitle(collection.id, channel);
await state.toggleCollectionTitle(collection.id, channel);
expect(state.exportLibrary(), original);
expect(state.collections.single.titles.single.toJson(), movie.toJson());
await state.toggleCollectionTitle(collection.id, movie);
await state.toggleCollectionTitle(collection.id, channel);
expect(state.collections.single.titles, isEmpty);
});
test(
'blocked history mutations cannot replace same-ID movie progress',
() async {
final state = AppState(api: LocalLibraryApi());
addTearDown(state.dispose);
await state.recordWatch(movie, positionSeconds: 15);
final original = state.exportLibrary();
await state.recordWatch(channel, positionSeconds: 30);
expect(state.exportLibrary(), original);
expect(state.history.single.title.toJson(), movie.toJson());
expect(state.resumeSeconds(42), 15);
expect(state.resumeSeconds(42, kind: 'channel'), 0);
await state.removeHistory(42, kind: 'channel');
expect(state.resumeSeconds(42), 15);
await state.removeHistory(42);
await state.recordWatch(channel, positionSeconds: 30);
expect(state.history, isEmpty);
},
);
test('blocked watchlist mutations cannot replace a same-ID movie', () async {
final store = await SharedPreferences.getInstance();
final state = AppState(api: LocalLibraryApi(), storage: store);
addTearDown(state.dispose);
await state.toggleSaved(movie);
final original = state.exportLibrary();
var notifications = 0;
state.addListener(() => notifications++);
await state.toggleSaved(channel);
await state.toggleSaved(channel);
expect(state.exportLibrary(), original);
expect(store.getString(AppState.storageKey), original);
expect(notifications, 0);
expect(state.saved.single.toJson(), movie.toJson());
expect(state.hasSaved(42), isTrue);
expect(state.hasSaved(42, kind: 'channel'), isFalse);
await state.removeSaved(42, kind: 'channel');
expect(state.saved.single.toJson(), movie.toJson());
await state.removeSaved(42);
await state.toggleSaved(channel);
expect(state.saved, isEmpty);
expect(state.hasSaved(42, kind: 'channel'), isFalse);
});
test(
'legacy version-one titles without type still load as posters',
() async {
final state = AppState(api: LocalLibraryApi());
addTearDown(state.dispose);
final document =
jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
document['saved'] = [
channel.toJson(),
{'id': 42, 'title': 'Legacy movie'},
];
await state.importLibrary(jsonEncode(document));
expect(state.hasSaved(42), isTrue);
expect(state.hasSaved(42, kind: 'channel'), isFalse);
expect(state.saved.single.libraryKey, 'poster:42');
expect(state.saved.single.title, 'Legacy movie');
final original = state.exportLibrary();
await state.toggleSaved(channel);
expect(state.exportLibrary(), original);
},
);
test(
'movie and series aliases share the poster namespace during mutation',
() async {
final state = AppState(api: LocalLibraryApi());
addTearDown(state.dispose);
const series = TitleRecord(id: 42, title: 'Series', type: 'series');
const serie = TitleRecord(id: 42, title: 'Serie', type: 'serie');
expect(movie.libraryKey, series.libraryKey);
expect(series.libraryKey, serie.libraryKey);
await state.toggleSaved(series);
expect(state.hasSaved(42, kind: 'serie'), isTrue);
await state.toggleSaved(serie);
expect(state.saved, isEmpty);
await state.recordWatch(series, episodeId: 7, positionSeconds: 10);
await state.recordWatch(serie, episodeId: 7, positionSeconds: 20);
expect(state.history.single.positionSeconds, 20);
},
);
for (final section in ['saved', 'history', 'collections']) {
test(
'$section rejects same-namespace duplicate imports atomically',
() async {
final store = await SharedPreferences.getInstance();
final state = AppState(api: LocalLibraryApi(), storage: store);
addTearDown(state.dispose);
await state.toggleSaved(movie);
final original = state.exportLibrary();
var notifications = 0;
state.addListener(() => notifications++);
final document = jsonDecode(original) as Map<String, dynamic>;
const series = TitleRecord(id: 42, title: 'Series', type: 'serie');
if (section == 'history') {
document[section] = [
WatchEntry(title: channel).toJson(),
WatchEntry(title: movie).toJson(),
WatchEntry(title: series).toJson(),
];
} else if (section == 'collections') {
document[section] = [
LocalCollection(
id: 'both',
name: 'Both',
titles: [channel, movie, series],
).toJson(),
];
} else {
document[section] = [
channel.toJson(),
movie.toJson(),
series.toJson(),
];
}
await expectLater(
state.importLibrary(jsonEncode(document)),
throwsFormatException,
);
expect(state.exportLibrary(), original);
expect(store.getString(AppState.storageKey), original);
expect(notifications, 0);
},
);
}
}