fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
File diff suppressed because it is too large Load Diff
+486 -9
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
@@ -62,30 +64,362 @@ void main() {
expect(row.mediaIndex, 7);
});
test('insertDownload uses InsertMode.insertOrReplace (re-insert overwrites)', () async {
test('atomically updates metadata and attempt state while preserving the row and physical fields', () async {
await db.insertDownload(
serverId: ServerId('srv'),
clientScopeId: 'scope-old',
ratingKey: '100',
globalKey: 'srv:100',
type: 'movie',
status: DownloadStatus.queued.index,
mediaIndex: 1,
mediaSourceId: 'source-old',
);
final original = (await db.getDownloadedMedia('srv:100'))!;
await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write(
const DownloadedMediaCompanion(
progress: Value(50),
downloadedBytes: Value(500),
totalBytes: Value(1000),
videoFilePath: Value('downloads/video.mkv'),
safRootUri: Value('content://downloads'),
thumbPath: Value('downloads/thumb.jpg'),
downloadedAt: Value(1234),
errorMessage: Value('old error'),
retryCount: Value(2),
bgTaskId: Value('current-task'),
),
);
// Mark progress so we can detect a replace.
await db.updateDownloadProgress('srv:100', 50, 500, 1000);
// Re-insert with the same globalKey — should replace, resetting progress to default 0.
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: '100',
serverId: ServerId('srv-new'),
clientScopeId: 'scope-new',
ratingKey: '100-new',
globalKey: 'srv:100',
type: 'movie',
type: 'episode',
parentRatingKey: 'season-new',
grandparentRatingKey: 'show-new',
status: DownloadStatus.failed.index,
mediaIndex: 3,
mediaSourceId: 'source-new',
);
final row = (await db.select(db.downloadedMedia).get()).single;
expect(row.id, original.id);
expect(row.serverId, 'srv-new');
expect(row.clientScopeId, 'scope-new');
expect(row.ratingKey, '100-new');
expect(row.type, 'episode');
expect(row.parentRatingKey, 'season-new');
expect(row.grandparentRatingKey, 'show-new');
expect(row.status, DownloadStatus.failed.index);
expect(row.mediaIndex, 3);
expect(row.mediaSourceId, 'source-new');
expect(row.progress, 0);
expect(row.downloadedBytes, 0);
expect(row.totalBytes, isNull);
expect(row.errorMessage, isNull);
expect(row.retryCount, 0);
expect(row.videoFilePath, 'downloads/video.mkv');
expect(row.safRootUri, 'content://downloads');
expect(row.thumbPath, 'downloads/thumb.jpg');
expect(row.downloadedAt, 1234);
expect(row.bgTaskId, 'current-task');
});
});
group('insertQueuedDownload', () {
test('atomically persists media identity, scope, policy, and queue state', () async {
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 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);
});
test('requeues retryable rows without replacing identity or physical fields', () async {
await db.insertDownload(
serverId: ServerId('srv'),
clientScopeId: 'scope-original',
ratingKey: 'existing',
globalKey: 'srv:existing',
type: 'movie',
parentRatingKey: 'season-original',
grandparentRatingKey: 'show-original',
status: DownloadStatus.failed.index,
mediaIndex: 2,
mediaSourceId: 'source-original',
);
final original = (await db.getDownloadedMedia('srv:existing'))!;
await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:existing'))).write(
const DownloadedMediaCompanion(
progress: Value(41),
downloadedBytes: Value(410),
totalBytes: Value(1000),
videoFilePath: Value('downloads/video.mkv'),
safRootUri: Value('content://downloads'),
thumbPath: Value('downloads/thumb.jpg'),
downloadedAt: Value(1234),
errorMessage: Value('network error'),
retryCount: Value(3),
bgTaskId: Value('stale-task'),
),
);
final outcome = await db.insertQueuedDownload(
serverId: ServerId('different-server'),
clientScopeId: 'scope-new',
ratingKey: 'different-rating-key',
globalKey: 'srv:existing',
type: 'episode',
parentRatingKey: 'season-new',
grandparentRatingKey: 'show-new',
mediaIndex: 9,
mediaSourceId: 'source-new',
priority: 4,
downloadSubtitles: false,
downloadArtwork: false,
);
expect(outcome, QueueDownloadOutcome.admitted);
final requeued = (await db.getDownloadedMedia('srv:existing'))!;
expect(requeued.id, original.id);
expect(requeued.serverId, 'different-server');
expect(requeued.clientScopeId, 'scope-new');
expect(requeued.ratingKey, 'different-rating-key');
expect(requeued.type, 'episode');
expect(requeued.parentRatingKey, 'season-new');
expect(requeued.grandparentRatingKey, 'show-new');
expect(requeued.mediaIndex, 9);
expect(requeued.mediaSourceId, 'source-new');
expect(requeued.status, DownloadStatus.queued.index);
expect(requeued.progress, 0);
expect(requeued.downloadedBytes, 0);
expect(requeued.totalBytes, isNull);
expect(requeued.errorMessage, isNull);
expect(requeued.retryCount, 0);
expect(requeued.bgTaskId, isNull);
expect(requeued.videoFilePath, 'downloads/video.mkv');
expect(requeued.safRootUri, 'content://downloads');
expect(requeued.thumbPath, 'downloads/thumb.jpg');
expect(requeued.downloadedAt, 1234);
final queue = (await db.select(db.downloadQueue).get()).single;
expect(queue.priority, 4);
expect(queue.downloadSubtitles, isFalse);
expect(queue.downloadArtwork, isFalse);
});
test('admits cancelled and partial rows for a fresh attempt', () async {
for (final status in [DownloadStatus.cancelled, DownloadStatus.partial]) {
final key = 'srv:${status.name}';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: status.name,
globalKey: key,
type: 'movie',
status: status.index,
);
await db.updateDownloadProgress(key, 75, 750, 1000);
await db.updateDownloadError(key, 'old failure');
expect(
await db.insertQueuedDownload(
serverId: ServerId('srv'),
ratingKey: status.name,
globalKey: key,
type: 'movie',
),
QueueDownloadOutcome.admitted,
);
final row = (await db.getDownloadedMedia(key))!;
expect(row.status, DownloadStatus.queued.index);
expect(row.progress, 0);
expect(row.downloadedBytes, 0);
expect(row.totalBytes, isNull);
expect(row.errorMessage, isNull);
expect(row.retryCount, 0);
}
expect(await db.select(db.downloadQueue).get(), hasLength(2));
});
test('preserves active, paused, and completed rows without creating queue work', () async {
for (final status in [DownloadStatus.downloading, DownloadStatus.paused, DownloadStatus.completed]) {
final key = 'srv:${status.name}';
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: status.name,
globalKey: key,
type: 'movie',
status: status.index,
);
await db.updateDownloadProgress(key, 63, 630, 1000);
final before = (await db.getDownloadedMedia(key))!;
expect(
await db.insertQueuedDownload(
serverId: ServerId('other'),
ratingKey: 'replacement',
globalKey: key,
type: 'episode',
priority: 9,
),
QueueDownloadOutcome.unchanged,
);
expect(await db.getDownloadedMedia(key), before);
}
expect(await db.select(db.downloadQueue).get(), isEmpty);
});
test('refreshes policy for an already queued row without rewriting media', () async {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'queued',
globalKey: 'srv:queued',
type: 'movie',
status: DownloadStatus.queued.index,
);
await db.updateDownloadProgress('srv:queued', 12, 120, 1000);
await db.addToQueue(mediaGlobalKey: 'srv:queued', priority: 1);
final before = (await db.getDownloadedMedia('srv:queued'))!;
final outcome = await db.insertQueuedDownload(
serverId: ServerId('other'),
ratingKey: 'replacement',
globalKey: 'srv:queued',
type: 'episode',
priority: 8,
downloadSubtitles: false,
downloadArtwork: false,
);
expect(outcome, QueueDownloadOutcome.alreadyQueued);
expect(await db.getDownloadedMedia('srv:queued'), before);
final queue = (await db.select(db.downloadQueue).get()).single;
expect(queue.priority, 8);
expect(queue.downloadSubtitles, isFalse);
expect(queue.downloadArtwork, isFalse);
});
test('a state advance during retry admission wins over the stale requeue', () async {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'race',
globalKey: 'srv:race',
type: 'movie',
status: DownloadStatus.failed.index,
);
await db.customStatement('''
CREATE TRIGGER advance_retry_state
BEFORE UPDATE OF status ON downloaded_media
WHEN OLD.global_key = 'srv:race'
AND OLD.status = ${DownloadStatus.failed.index}
AND NEW.status = ${DownloadStatus.queued.index}
BEGIN
UPDATE downloaded_media
SET status = ${DownloadStatus.downloading.index}
WHERE id = OLD.id;
SELECT RAISE(IGNORE);
END
''');
final outcome = await db.insertQueuedDownload(
serverId: ServerId('srv'),
ratingKey: 'race',
globalKey: 'srv:race',
type: 'movie',
);
expect(outcome, QueueDownloadOutcome.unchanged);
expect((await db.getDownloadedMedia('srv:race'))?.status, DownloadStatus.downloading.index);
expect(await db.select(db.downloadQueue).get(), isEmpty);
});
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(
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,
);
});
});
@@ -204,6 +538,93 @@ void main() {
// priority 5 wins; srv:3 added before srv:2.
expect(next!.mediaGlobalKey, 'srv:3');
});
test('repairs only missing queued rows and preserves existing queue policy', () async {
Future<void> seedMedia(String key, DownloadStatus status) {
return db.insertDownload(
serverId: ServerId('srv'),
ratingKey: key.substring('srv:'.length),
globalKey: key,
type: 'movie',
status: status.index,
);
}
await seedMedia('srv:missing', DownloadStatus.queued);
await seedMedia('srv:custom', DownloadStatus.queued);
await seedMedia('srv:downloading', DownloadStatus.downloading);
const customAddedAt = 123456;
await db
.into(db.downloadQueue)
.insert(
DownloadQueueCompanion.insert(
mediaGlobalKey: 'srv:custom',
priority: const Value(-1),
addedAt: customAddedAt,
downloadSubtitles: const Value(false),
downloadArtwork: const Value(false),
),
);
await db.addToQueue(mediaGlobalKey: 'srv:orphan', priority: 9);
expect(await db.repairMissingQueuedDownloadEntries(), 1);
expect(await db.repairMissingQueuedDownloadEntries(), 0);
final queueRows = {for (final row in await db.select(db.downloadQueue).get()) row.mediaGlobalKey: row};
expect(queueRows.keys, {'srv:missing', 'srv:custom', 'srv:orphan'});
final repaired = queueRows['srv:missing']!;
expect(repaired.priority, 0);
expect(repaired.downloadSubtitles, isTrue);
expect(repaired.downloadArtwork, isTrue);
final custom = queueRows['srv:custom']!;
expect(custom.priority, -1);
expect(custom.addedAt, customAddedAt);
expect(custom.downloadSubtitles, isFalse);
expect(custom.downloadArtwork, isFalse);
expect(queueRows['srv:orphan']?.priority, 9);
expect(await db.getNextQueueItem(), isNotNull);
expect((await db.getNextQueueItem())?.mediaGlobalKey, 'srv:missing');
});
test('supplementary query returns only completed videos without making them primary work', () async {
Future<void> seedMedia(String key, DownloadStatus status, {bool video = false}) async {
await db.insertDownload(
serverId: ServerId('srv'),
ratingKey: key.substring('srv:'.length),
globalKey: key,
type: 'movie',
status: status.index,
);
if (video) await db.updateVideoFilePath(key, 'downloads/${key.substring(4)}/video.mkv');
await db.addToQueue(
mediaGlobalKey: key,
priority: key == 'srv:queued' ? 5 : 0,
downloadSubtitles: key == 'srv:completed-video',
downloadArtwork: false,
);
}
await seedMedia('srv:queued', DownloadStatus.queued);
await seedMedia('srv:downloading', DownloadStatus.downloading);
await seedMedia('srv:completed-video', DownloadStatus.completed, video: true);
await seedMedia('srv:completed-no-video', DownloadStatus.completed);
final pending = await db.getPendingSupplementaryQueueItems();
expect(pending, hasLength(1));
expect(pending.single.mediaGlobalKey, 'srv:completed-video');
expect(pending.single.downloadSubtitles, isTrue);
expect(pending.single.downloadArtwork, isFalse);
expect((await db.getNextQueueItem())?.mediaGlobalKey, 'srv:queued');
await db.removeFromQueue('srv:completed-video');
expect(await db.getPendingSupplementaryQueueItems(), isEmpty);
await db.addToQueue(mediaGlobalKey: 'srv:completed-video');
await db.deleteDownload('srv:completed-video');
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:completed-video'))).get(),
isEmpty,
);
});
});
// ============================================================
@@ -253,6 +674,25 @@ void main() {
expect(r.downloadedAt! <= after, isTrue);
});
test('SAF root assignment and reference queries track physical rows', () async {
await seed(key: 'srv:100');
await seed(key: 'srv:200');
await db.updateDownloadSafRoot('srv:100', 'content://root-a');
await db.updateDownloadSafRoot('srv:200', 'content://root-a');
expect(await db.countDownloadsReferencingSafRoot('content://root-a'), 2);
expect(await db.getReferencedDownloadSafRoots(), {'content://root-a'});
await db.updateDownloadSafRoot('srv:200', 'content://root-b');
expect(await db.countDownloadsReferencingSafRoot('content://root-a'), 1);
expect(await db.countDownloadsReferencingSafRoot('content://root-b'), 1);
expect(await db.getReferencedDownloadSafRoots(), {'content://root-a', 'content://root-b'});
await db.updateDownloadSafRoot('srv:100', null);
expect(await db.countDownloadsReferencingSafRoot('content://root-a'), 0);
expect(await db.getReferencedDownloadSafRoots(), {'content://root-b'});
});
test('updateArtworkPaths sets thumbPath; null clears it', () async {
await seed();
await db.updateArtworkPaths(globalKey: 'srv:100', thumbPath: '/tmp/thumb.jpg');
@@ -552,6 +992,41 @@ void main() {
.insert(ConnectionsCompanion.insert(id: id, kind: 'plex', displayName: id, configJson: '{}', createdAt: 0));
}
test('repeated claims preserve creation time and omitted metadata', () async {
await db
.into(db.downloadOwners)
.insert(
DownloadOwnersCompanion.insert(
profileId: 'profile-a',
globalKey: 'srv:100',
backend: const Value('plex'),
clientScopeId: const Value('scope-a'),
createdAt: 1234,
),
);
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100');
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100');
final owner = (await db.select(db.downloadOwners).get()).single;
expect(owner.createdAt, 1234);
expect(owner.backend, 'plex');
expect(owner.clientScopeId, 'scope-a');
});
test('repeated claims upgrade each supplied non-null metadata field', () async {
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100');
final createdAt = (await db.select(db.downloadOwners).get()).single.createdAt;
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100', backendId: 'jellyfin');
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100', clientScopeId: 'srv/user-a');
final owner = (await db.select(db.downloadOwners).get()).single;
expect(owner.createdAt, createdAt);
expect(owner.backend, 'jellyfin');
expect(owner.clientScopeId, 'srv/user-a');
});
test('owner counts ignore orphan local profiles', () async {
await insertProfile('profile-a');
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100');
@@ -602,7 +1077,9 @@ void main() {
);
await db.addToQueue(mediaGlobalKey: 'srv:200');
await db.deleteDownload('srv:100');
await db.updateDownloadSafRoot('srv:100', 'content://root-a');
final removedRoot = await db.deleteDownload('srv:100');
expect(removedRoot, 'content://root-a');
final media = await db.select(db.downloadedMedia).get();
expect(media.map((m) => m.globalKey).toList(), ['srv:200']);
@@ -613,7 +1090,7 @@ void main() {
test('deleteDownload on a missing globalKey is a no-op', () async {
// Should not throw.
await db.deleteDownload('nope:nope');
expect(await db.deleteDownload('nope:nope'), isNull);
expect(await db.select(db.downloadedMedia).get(), isEmpty);
expect(await db.select(db.downloadQueue).get(), isEmpty);
});
File diff suppressed because it is too large Load Diff