fix(app): restore playback and state lifecycle contracts

This commit is contained in:
edde746
2026-07-25 16:16:04 +02:00
parent 73f4a3e055
commit 4af77f4696
58 changed files with 2448 additions and 363 deletions
+25 -1
View File
@@ -22,6 +22,19 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import '../test_helpers/prefs.dart';
final class _EnsureOpenTrackingInterceptor extends QueryInterceptor {
var calls = 0;
var completed = false;
@override
Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
calls++;
final result = await executor.ensureOpen(user);
completed = true;
return result;
}
}
void main() {
final suite = _AppDatabaseTestSuite();
suite.registerTests();
@@ -731,7 +744,18 @@ class _AppDatabaseTestSuite {
await seeded.close();
seeded = null;
reopened = AppDatabase.forTesting(NativeDatabase(file));
resetSharedPreferencesForTest();
final prefs = await BaseSharedPreferencesService.sharedCache();
final openTracker = _EnsureOpenTrackingInterceptor();
final bootstrap = await AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) => NativeDatabase(databaseFile).interceptWith(openTracker),
);
reopened = bootstrap.database;
expect(openTracker.calls, greaterThanOrEqualTo(1));
expect(openTracker.completed, isTrue);
final owners = await reopened.select(reopened.downloadOwners).get();
DownloadOwnerItem owner(String profileId, String globalKey) =>
owners.singleWhere((row) => row.profileId == profileId && row.globalKey == globalKey);
+100 -83
View File
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as path;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/models/download_models.dart';
@@ -130,45 +131,50 @@ void main() {
group('insertQueuedDownload', () {
test('atomically persists media identity, scope, policy, and queue state', () async {
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_');
final databaseFile = File('${tempDir.path}/downloads.sqlite');
addTearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final outcome = await db.insertQueuedDownload(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: 'episode-1',
globalKey: 'srv:episode-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
mediaIndex: 3,
mediaSourceId: 'source-3',
priority: 7,
downloadSubtitles: false,
downloadArtwork: true,
);
expect(outcome, QueueDownloadOutcome.admitted);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
try {
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final outcome = await db.insertQueuedDownload(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: 'episode-1',
globalKey: 'srv:episode-1',
type: 'episode',
parentRatingKey: 'season-1',
grandparentRatingKey: 'show-1',
mediaIndex: 3,
mediaSourceId: 'source-3',
priority: 7,
downloadSubtitles: false,
downloadArtwork: true,
);
expect(outcome, QueueDownloadOutcome.admitted);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final media = (await db.select(db.downloadedMedia).get()).single;
final queued = (await db.select(db.downloadQueue).get()).single;
expect(media.serverId, 'srv');
expect(media.clientScopeId, 'srv/user-a');
expect(media.ratingKey, 'episode-1');
expect(media.parentRatingKey, 'season-1');
expect(media.grandparentRatingKey, 'show-1');
expect(media.status, DownloadStatus.queued.index);
expect(media.mediaIndex, 3);
expect(media.mediaSourceId, 'source-3');
expect(queued.mediaGlobalKey, media.globalKey);
expect(queued.priority, 7);
expect(queued.downloadSubtitles, isFalse);
expect(queued.downloadArtwork, isTrue);
final media = (await db.select(db.downloadedMedia).get()).single;
final queued = (await db.select(db.downloadQueue).get()).single;
expect(media.serverId, 'srv');
expect(media.clientScopeId, 'srv/user-a');
expect(media.ratingKey, 'episode-1');
expect(media.parentRatingKey, 'season-1');
expect(media.grandparentRatingKey, 'show-1');
expect(media.status, DownloadStatus.queued.index);
expect(media.mediaIndex, 3);
expect(media.mediaSourceId, 'source-3');
expect(queued.mediaGlobalKey, media.globalKey);
expect(queued.priority, 7);
expect(queued.downloadSubtitles, isFalse);
expect(queued.downloadArtwork, isTrue);
} finally {
await db.close();
db = AppDatabase.forTesting(NativeDatabase.memory());
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
}
});
test('requeues retryable rows without replacing identity or physical fields', () async {
@@ -369,57 +375,68 @@ void main() {
});
test('rolls back both new and replacement media rows when queue insertion fails', () async {
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_');
final databaseFile = File('${tempDir.path}/downloads.sqlite');
addTearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'existing',
globalKey: 'srv:existing',
type: 'movie',
status: DownloadStatus.failed.index,
);
await db.updateDownloadProgress('srv:existing', 41, 410, 1000);
await db.customStatement('''
CREATE TRIGGER reject_download_queue_insert
BEFORE INSERT ON download_queue
BEGIN
SELECT RAISE(ABORT, 'queue insert rejected');
END
''');
await expectLater(
db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'),
throwsA(anything),
);
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty);
await expectLater(
db.insertQueuedDownload(
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_');
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
try {
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'existing',
globalKey: 'srv:existing',
type: 'movie',
),
throwsA(anything),
);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty);
final preserved = await db.getDownloadedMedia('srv:existing');
expect(preserved?.status, DownloadStatus.failed.index);
expect(preserved?.progress, 41);
expect(preserved?.downloadedBytes, 410);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(),
isEmpty,
);
status: DownloadStatus.failed.index,
);
await db.updateDownloadProgress('srv:existing', 41, 410, 1000);
await db.customStatement('''
CREATE TRIGGER reject_download_queue_insert
BEFORE INSERT ON download_queue
BEGIN
SELECT RAISE(ABORT, 'queue insert rejected');
END
''');
await expectLater(
db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'),
throwsA(anything),
);
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(),
isEmpty,
);
await expectLater(
db.insertQueuedDownload(
serverId: ServerId('srv'),
ratingKey: 'existing',
globalKey: 'srv:existing',
type: 'movie',
),
throwsA(anything),
);
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(),
isEmpty,
);
final preserved = await db.getDownloadedMedia('srv:existing');
expect(preserved?.status, DownloadStatus.failed.index);
expect(preserved?.progress, 41);
expect(preserved?.downloadedBytes, 410);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(),
isEmpty,
);
} finally {
await db.close();
db = AppDatabase.forTesting(NativeDatabase.memory());
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
}
});
});