Files
plezy/test/database/app_database_test.dart
T

1197 lines
45 KiB
Dart

import 'dart:io';
import 'package:plezy/media/ids.dart';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
void main() {
final suite = _AppDatabaseTestSuite();
suite.registerTests();
}
class _AppDatabaseTestSuite {
late AppDatabase db;
void registerTests() {
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
});
_registerSchemaTests();
_registerApiCacheTests();
_registerDownloadedMediaTests();
_registerOfflineWatchProgressTests();
_registerSyncRulesTests();
}
void _registerSchemaTests() {
// ============================================================
// Schema sanity
// ============================================================
group('schema', () {
test('all tables are accessible and start empty', () async {
expect(await db.select(db.downloadedMedia).get(), isEmpty);
expect(await db.select(db.downloadOwners).get(), isEmpty);
expect(await db.select(db.downloadQueue).get(), isEmpty);
expect(await db.select(db.apiCache).get(), isEmpty);
expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
expect(await db.select(db.syncRules).get(), isEmpty);
expect(await db.select(db.connections).get(), isEmpty);
expect(await db.select(db.profiles).get(), isEmpty);
expect(await db.select(db.profileConnections).get(), isEmpty);
});
test('ProfileConnections has no profile_id FK (virtual plex_home profiles)', () async {
// v20 dropped the profile_id FK so virtual Plex Home profiles can
// persist join rows without a parent `profiles` row. Profile deletion
// instead cleans up join rows explicitly (via the teardown flow's
// removeAllProfileConnectionsAndCleanup) before deleting the profile,
// so the cascade isn't needed.
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(id: 'c1', kind: 'plex', displayName: 'C1', configJson: '{}', createdAt: now),
);
// No `profiles` row inserted — this would have failed pre-v20.
await db
.into(db.profileConnections)
.insert(
ProfileConnectionsCompanion.insert(
profileId: 'plex-home-c1-uuid',
connectionId: 'c1',
userIdentifier: 'uuid',
),
);
expect(await db.select(db.profileConnections).get(), hasLength(1));
});
test('ProfileConnections FK cascades when a Connection is deleted', () async {
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.connections)
.insert(
ConnectionsCompanion.insert(id: 'c2', kind: 'plex', displayName: 'C2', configJson: '{}', createdAt: now),
);
await db
.into(db.profiles)
.insert(
ProfilesCompanion.insert(id: 'p2', kind: 'local', displayName: 'P2', configJson: '{}', createdAt: now),
);
await db
.into(db.profileConnections)
.insert(ProfileConnectionsCompanion.insert(profileId: 'p2', connectionId: 'c2', userIdentifier: 'u2'));
await (db.delete(db.connections)..where((t) => t.id.equals('c2'))).go();
expect(await db.select(db.profileConnections).get(), isEmpty);
});
test('hot-query indices exist in sqlite_master', () async {
// sqlite_master rows let us assert the indices physically exist
// without depending on Drift's `Migrator` having run them.
final rows = await db.customSelect("SELECT name FROM sqlite_master WHERE type = 'index'").get();
final names = rows.map((r) => r.read<String>('name')).toSet();
expect(
names,
containsAll(['idx_profiles_kind', 'idx_profile_connections_profile_id', 'idx_offline_watch_progress_server']),
);
});
test('retried v14 migration tolerates existing indices', () async {
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_db_migration_test_');
final file = File('${tempDir.path}/plezy_downloads.db');
AppDatabase? seeded;
AppDatabase? reopened;
try {
seeded = AppDatabase.forTesting(NativeDatabase(file));
await seeded.select(seeded.connections).get();
await seeded.customStatement('PRAGMA user_version = 13');
await seeded.close();
seeded = null;
reopened = AppDatabase.forTesting(NativeDatabase(file));
expect(await reopened.select(reopened.connections).get(), isEmpty);
final rows = await reopened
.customSelect("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_connections_kind'")
.get();
expect(rows, hasLength(1));
} finally {
await reopened?.close();
await seeded?.close();
await tempDir.delete(recursive: true);
db = AppDatabase.forTesting(NativeDatabase.memory());
}
});
});
_registerLegacyDesktopMigrationTests();
}
void _registerLegacyDesktopMigrationTests() {
// ============================================================
// Legacy desktop DB-file relocation (Documents → AppSupport).
// Regression coverage for #1022: cross-drive rename (e.g. OneDrive
// Documents on X:, AppData on C:) used to throw an uncaught
// FileSystemException out of _openConnection and strand the splash.
// ============================================================
group('legacy desktop DB migration', () {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('plezy_legacy_migration_test_');
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('no-op when source does not exist', () async {
final source = File('${tempDir.path}/Documents/plezy_downloads.db');
final target = File('${tempDir.path}/AppData/plezy_downloads.db');
await target.parent.create(recursive: true);
await migrateLegacyDesktopDatabase(sourceOverride: source, target: target);
expect(await source.exists(), isFalse);
expect(await target.exists(), isFalse);
});
test('rename happy path moves the file and preserves content', () async {
final source = File('${tempDir.path}/Documents/plezy_downloads.db');
final target = File('${tempDir.path}/AppData/plezy_downloads.db');
await source.parent.create(recursive: true);
await target.parent.create(recursive: true);
await source.writeAsBytes([1, 2, 3, 4, 5]);
await migrateLegacyDesktopDatabase(sourceOverride: source, target: target);
expect(await source.exists(), isFalse);
expect(await target.exists(), isTrue);
expect(await target.readAsBytes(), [1, 2, 3, 4, 5]);
});
test('cross-drive rename failure falls back to copy + delete', () async {
// Simulate Windows ERROR_NOT_SAME_DEVICE by throwing the same
// exception shape `File.rename` would emit when source and target
// live on different volumes.
final source = File('${tempDir.path}/Documents/plezy_downloads.db');
final target = File('${tempDir.path}/AppData/plezy_downloads.db');
await source.parent.create(recursive: true);
await target.parent.create(recursive: true);
await source.writeAsBytes([9, 8, 7]);
await migrateLegacyDesktopDatabase(
sourceOverride: source,
target: target,
renameOverride: (_, _) => throw const FileSystemException(
'Cannot rename file across drives',
'',
OSError('The system cannot move the file to a different disk drive', 17),
),
);
expect(await source.exists(), isFalse, reason: 'source should be deleted after successful copy');
expect(await target.exists(), isTrue);
expect(await target.readAsBytes(), [9, 8, 7]);
});
test('copy failure leaves source intact and never throws', () async {
final source = File('${tempDir.path}/Documents/plezy_downloads.db');
// Point target at a non-existent directory so copy fails. The
// helper must swallow the error — splash boot must never see it.
final target = File('${tempDir.path}/does-not-exist/AppData/plezy_downloads.db');
await source.parent.create(recursive: true);
await source.writeAsBytes([0xAA, 0xBB]);
await expectLater(
migrateLegacyDesktopDatabase(
sourceOverride: source,
target: target,
renameOverride: (_, _) => throw const FileSystemException('cross-drive', ''),
),
completes,
);
expect(await source.exists(), isTrue, reason: 'source should be preserved when copy fails');
expect(await source.readAsBytes(), [0xAA, 0xBB]);
expect(await target.exists(), isFalse);
});
test('documents directory lookup failure is a silent no-op', () async {
final previousPathProvider = PathProviderPlatform.instance;
final target = File('${tempDir.path}/AppData/plezy_downloads.db');
PathProviderPlatform.instance = _ThrowingDocumentsPathProvider();
try {
await expectLater(migrateLegacyDesktopDatabase(target: target), completes);
} finally {
PathProviderPlatform.instance = previousPathProvider;
}
expect(await target.exists(), isFalse);
});
});
}
void _registerApiCacheTests() {
// ============================================================
// ApiCache schema defaults and constraints
// ============================================================
group('ApiCache', () {
test('default pinned=false, custom pinned=true is honored', () async {
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k1', data: 'a'));
await db
.into(db.apiCache)
.insert(ApiCacheCompanion.insert(cacheKey: 'k2', data: 'b', pinned: const Value(true)));
final rows = await (db.select(db.apiCache)..orderBy([(t) => OrderingTerm.asc(t.cacheKey)])).get();
expect(rows.map((r) => r.pinned).toList(), [false, true]);
});
test('cacheKey is the primary key (duplicate insert without replace fails)', () async {
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'first'));
expect(
() => db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'second')),
throwsA(isA<Exception>()),
);
});
});
}
void _registerDownloadedMediaTests() {
// ============================================================
// DownloadedMedia: persistence, defaults, constraints, and helpers
// ============================================================
group('DownloadedMedia', () {
Future<int> insertMovie({
String serverId = 'srv1',
String? clientScopeId,
String ratingKey = '100',
int status = 0, // queued
int progress = 0,
}) async {
return db
.into(db.downloadedMedia)
.insert(
DownloadedMediaCompanion.insert(
serverId: serverId,
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: '$serverId:$ratingKey',
type: 'movie',
status: status,
progress: Value(progress),
),
);
}
test('insert + select round-trip preserves fields', () async {
await insertMovie();
final rows = await db.select(db.downloadedMedia).get();
expect(rows, hasLength(1));
expect(rows.first.serverId, 'srv1');
expect(rows.first.clientScopeId, isNull);
expect(rows.first.ratingKey, '100');
expect(rows.first.globalKey, 'srv1:100');
expect(rows.first.type, 'movie');
expect(rows.first.status, 0);
expect(rows.first.progress, 0);
expect(rows.first.downloadedBytes, 0); // default
expect(rows.first.retryCount, 0); // default
expect(rows.first.mediaIndex, 0); // default
expect(rows.first.bgTaskId, isNull);
expect(rows.first.totalBytes, isNull);
});
test('clientScopeId is persisted for user-scoped downloads', () async {
await insertMovie(serverId: 'jf-machine', clientScopeId: 'jf-machine/user-a');
final row = await db.select(db.downloadedMedia).getSingle();
expect(row.serverId, 'jf-machine');
expect(row.clientScopeId, 'jf-machine/user-a');
});
test('globalKey unique constraint blocks duplicate insert', () async {
await insertMovie();
expect(insertMovie(), throwsA(isA<Exception>()));
});
test('getAllDownloadedMetadata returns only completed items', () async {
await insertMovie(ratingKey: '1', status: DownloadStatus.queued.index);
await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index);
await insertMovie(ratingKey: '3', status: DownloadStatus.failed.index);
await insertMovie(ratingKey: '4', status: DownloadStatus.completed.index);
final completed = await db.getAllDownloadedMetadata();
expect(completed.map((i) => i.ratingKey).toSet(), {'2', '4'});
});
test('download owners keep profile visibility separate for one physical row', () async {
await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index);
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1');
await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:1');
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'});
expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv1:1'});
expect(await db.getDownloadOwnerCount('srv1:1'), 2);
await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1');
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty);
expect(await db.hasDownloadOwner('srv1:1'), isTrue);
expect(await db.hasDownloadOwner('srv1:1', excludingProfileId: 'profile-b'), isFalse);
});
test('adoptLegacyDownloadsForProfile claims only ownerless physical rows', () async {
await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index);
await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index);
await db.addDownloadOwner(profileId: 'profile-existing', globalKey: 'srv1:2');
await db.adoptLegacyDownloadsForProfile('profile-a');
expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'});
expect(await db.getDownloadOwnerKeysForProfile('profile-existing'), {'srv1:2'});
});
});
}
void _registerOfflineWatchProgressTests() {
// ============================================================
// OfflineWatchProgress helpers
// ============================================================
group('OfflineWatchProgress', () {
Future<int> insertAction({
String serverId = 's',
required String ratingKey,
String? profileId,
String? clientScopeId,
required String actionType,
required int updatedAt,
}) {
return db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: '$serverId:$ratingKey',
actionType: actionType,
createdAt: updatedAt,
updatedAt: updatedAt,
),
);
}
test('upsertProgressAction inserts a new progress row', () async {
await db.upsertProgressAction(
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 5000,
duration: 10000,
shouldMarkWatched: false,
);
final rows = await db.select(db.offlineWatchProgress).get();
expect(rows, hasLength(1));
expect(rows.first.globalKey, 'srv:42');
expect(rows.first.clientScopeId, isNull);
expect(rows.first.actionType, OfflineActionType.progress.id);
expect(rows.first.viewOffset, 5000);
expect(rows.first.duration, 10000);
expect(rows.first.shouldMarkWatched, isFalse);
expect(rows.first.syncAttempts, 0);
});
test('upsertProgressAction merges into the existing progress row', () async {
await db.upsertProgressAction(
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 1000,
duration: 10000,
shouldMarkWatched: false,
);
await db.upsertProgressAction(
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 9500,
duration: 10000,
shouldMarkWatched: true,
);
final rows = await db.select(db.offlineWatchProgress).get();
expect(rows, hasLength(1));
expect(rows.first.viewOffset, 9500);
expect(rows.first.shouldMarkWatched, isTrue);
});
test('upsertProgressAction keeps scoped Jellyfin users separate', () async {
await db.upsertProgressAction(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: '42',
viewOffset: 1000,
duration: 10000,
shouldMarkWatched: false,
);
await db.upsertProgressAction(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-b',
ratingKey: '42',
viewOffset: 9000,
duration: 10000,
shouldMarkWatched: true,
);
final rows = await (db.select(
db.offlineWatchProgress,
)..orderBy([(t) => OrderingTerm.asc(t.clientScopeId)])).get();
expect(rows, hasLength(2));
expect(rows.map((r) => r.clientScopeId), ['srv/user-a', 'srv/user-b']);
expect(rows.map((r) => r.viewOffset), [1000, 9000]);
});
test('insertWatchAction (watched) clears prior progress + insert single row', () async {
// Existing progress row for the same item
await db.upsertProgressAction(
serverId: ServerId('srv'),
ratingKey: '42',
viewOffset: 5000,
duration: 10000,
shouldMarkWatched: false,
);
await db.insertWatchAction(
serverId: ServerId('srv'),
ratingKey: '42',
actionType: OfflineActionType.watched.id,
);
final rows = await db.select(db.offlineWatchProgress).get();
expect(rows, hasLength(1));
expect(rows.first.actionType, OfflineActionType.watched.id);
expect(rows.first.viewOffset, isNull);
});
test('insertWatchAction clears only matching clientScopeId conflicts', () async {
await db.upsertProgressAction(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: '42',
viewOffset: 1000,
duration: 10000,
shouldMarkWatched: false,
);
await db.upsertProgressAction(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-b',
ratingKey: '42',
viewOffset: 2000,
duration: 10000,
shouldMarkWatched: false,
);
await db.insertWatchAction(
serverId: ServerId('srv'),
clientScopeId: 'srv/user-a',
ratingKey: '42',
actionType: OfflineActionType.watched.id,
);
final rows = await (db.select(
db.offlineWatchProgress,
)..orderBy([(t) => OrderingTerm.asc(t.clientScopeId), (t) => OrderingTerm.asc(t.actionType)])).get();
expect(rows, hasLength(2));
expect(rows.map((r) => (r.clientScopeId, r.actionType)).toList(), [
('srv/user-a', OfflineActionType.watched.id),
('srv/user-b', OfflineActionType.progress.id),
]);
});
test('getPendingWatchActions returns rows ordered by createdAt asc', () async {
// Inject deterministic createdAt by raw inserts
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
ratingKey: '1',
globalKey: 's:1',
actionType: OfflineActionType.watched.id,
createdAt: now + 100,
updatedAt: now + 100,
),
);
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
ratingKey: '2',
globalKey: 's:2',
actionType: OfflineActionType.watched.id,
createdAt: now + 50,
updatedAt: now + 50,
),
);
final pending = await db.getPendingWatchActions();
expect(pending.map((p) => p.ratingKey).toList(), ['2', '1']);
});
test('adoptLegacyOfflineWatchActionsForProfile claims null-profile rows', () async {
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(
profileId: 'profile-existing',
serverId: ServerId('s'),
ratingKey: '2',
actionType: OfflineActionType.watched.id,
);
await db.adoptLegacyOfflineWatchActionsForProfile('profile-a');
expect((await db.getPendingWatchActions(profileId: 'profile-a')).map((r) => r.ratingKey), ['1']);
expect((await db.getPendingWatchActions(profileId: 'profile-existing')).map((r) => r.ratingKey), ['2']);
});
test('getPendingWatchActionsForServer filters by serverId', () async {
await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('b'), ratingKey: '2', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('a'), ratingKey: '3', actionType: OfflineActionType.unwatched.id);
final aRows = await db.getPendingWatchActionsForServer(ServerId('a'));
expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'});
final bRows = await db.getPendingWatchActionsForServer(ServerId('b'));
expect(bRows.map((r) => r.ratingKey).toSet(), {'2'});
});
test('getLatestWatchAction picks the most recently updated row', () async {
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
ratingKey: '1',
globalKey: 's:1',
actionType: OfflineActionType.progress.id,
createdAt: now,
updatedAt: now - 100,
),
);
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
ratingKey: '1',
globalKey: 's:1',
actionType: OfflineActionType.watched.id,
createdAt: now,
updatedAt: now + 50,
),
);
final latest = await db.getLatestWatchAction('s:1');
expect(latest, isNotNull);
expect(latest!.actionType, OfflineActionType.watched.id);
});
test('getLatestWatchAction returns null when no rows', () async {
expect(await db.getLatestWatchAction('nope:nope'), isNull);
});
test('single and batched watch-action reads preserve newest-first ordering', () async {
final oldestId = await insertAction(
ratingKey: 'ordered',
actionType: OfflineActionType.progress.id,
updatedAt: 100,
);
final olderTieId = await insertAction(
ratingKey: 'ordered',
actionType: OfflineActionType.watched.id,
updatedAt: 200,
);
final newerTieId = await insertAction(
ratingKey: 'ordered',
actionType: OfflineActionType.unwatched.id,
updatedAt: 200,
);
final expectedIds = [newerTieId, olderTieId, oldestId];
final single = await db.getWatchActionsForKey('s:ordered');
final batched = await db.getWatchActionsForKeys({'s:ordered'});
expect(single.map((action) => action.id), expectedIds);
expect(batched['s:ordered']!.map((action) => action.id), expectedIds);
expect((await db.getLatestWatchAction('s:ordered'))!.id, newerTieId);
expect((await db.getLatestWatchActionsForKeys({'s:ordered'}))['s:ordered']!.id, newerTieId);
});
test('single and batched watch-action reads distinguish null and compound scopes', () async {
final nullScopeId = await insertAction(
ratingKey: 'scoped',
actionType: OfflineActionType.unwatched.id,
updatedAt: 100,
);
final compoundScopeId = await insertAction(
ratingKey: 'scoped',
clientScopeId: 's/user-a',
actionType: OfflineActionType.watched.id,
updatedAt: 200,
);
final singleNull = await db.getWatchActionsForKey('s:scoped', filterClientScope: true);
final singleCompound = await db.getWatchActionsForKey(
's:scoped',
clientScopeId: 's/user-a',
filterClientScope: true,
);
final batchedNull = await db.getWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': null},
);
final batchedCompound = await db.getWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': 's/user-a'},
);
expect(singleNull.map((action) => action.id), [nullScopeId]);
expect(singleCompound.map((action) => action.id), [compoundScopeId]);
expect(batchedNull['s:scoped']!.map((action) => action.id), [nullScopeId]);
expect(batchedCompound['s:scoped']!.map((action) => action.id), [compoundScopeId]);
expect((await db.getLatestWatchAction('s:scoped', filterClientScope: true))!.id, nullScopeId);
expect(
(await db.getLatestWatchAction('s:scoped', clientScopeId: 's/user-a', filterClientScope: true))!.id,
compoundScopeId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': null},
))['s:scoped']!.id,
nullScopeId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{'s:scoped'},
clientScopeIdsByGlobalKey: {'s:scoped': 's/user-a'},
))['s:scoped']!.id,
compoundScopeId,
);
});
test('single and batched watch-action reads isolate profiles', () async {
final profileAId = await insertAction(
ratingKey: 'profiled',
profileId: 'profile-a',
actionType: OfflineActionType.unwatched.id,
updatedAt: 100,
);
await insertAction(
ratingKey: 'profiled',
profileId: 'profile-b',
actionType: OfflineActionType.watched.id,
updatedAt: 200,
);
final single = await db.getWatchActionsForKey('s:profiled', profileId: 'profile-a', filterProfile: true);
final batched = await db.getWatchActionsForKeys({'s:profiled'}, profileId: 'profile-a', filterProfile: true);
expect(single.map((action) => action.id), [profileAId]);
expect(batched['s:profiled']!.map((action) => action.id), [profileAId]);
expect(
(await db.getLatestWatchAction('s:profiled', profileId: 'profile-a', filterProfile: true))!.id,
profileAId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{'s:profiled'},
profileId: 'profile-a',
filterProfile: true,
))['s:profiled']!.id,
profileAId,
);
});
test('watch-action query values treat wildcard characters literally', () async {
final exactId = await insertAction(
serverId: 'server_%',
ratingKey: 'item%_',
profileId: 'profile_%',
clientScopeId: 'server_%/user_1',
actionType: OfflineActionType.watched.id,
updatedAt: 100,
);
await insertAction(
serverId: 'server_%',
ratingKey: 'item%_',
profileId: 'profile_abc',
clientScopeId: 'server_%/user_1',
actionType: OfflineActionType.unwatched.id,
updatedAt: 400,
);
await insertAction(
serverId: 'server_%',
ratingKey: 'item%_',
profileId: 'profile_%',
clientScopeId: 'server_abc/user_a',
actionType: OfflineActionType.unwatched.id,
updatedAt: 300,
);
await insertAction(
serverId: 'server_abc',
ratingKey: 'itemZZZx',
profileId: 'profile_%',
clientScopeId: 'server_%/user_1',
actionType: OfflineActionType.unwatched.id,
updatedAt: 200,
);
const globalKey = 'server_%:item%_';
const scopes = {globalKey: 'server_%/user_1'};
final single = await db.getWatchActionsForKey(
globalKey,
profileId: 'profile_%',
filterProfile: true,
clientScopeId: 'server_%/user_1',
filterClientScope: true,
);
final batched = await db.getWatchActionsForKeys(
{globalKey},
profileId: 'profile_%',
filterProfile: true,
clientScopeIdsByGlobalKey: scopes,
);
expect(single.map((action) => action.id), [exactId]);
expect(single.single.serverId, 'server_%');
expect(single.single.ratingKey, 'item%_');
expect(batched[globalKey]!.map((action) => action.id), [exactId]);
expect(
(await db.getLatestWatchAction(
globalKey,
profileId: 'profile_%',
filterProfile: true,
clientScopeId: 'server_%/user_1',
filterClientScope: true,
))!.id,
exactId,
);
expect(
(await db.getLatestWatchActionsForKeys(
{globalKey},
profileId: 'profile_%',
filterProfile: true,
clientScopeIdsByGlobalKey: scopes,
))[globalKey]!.id,
exactId,
);
});
test('getLatestWatchActionsForKeys batches lookups, latest per key', () async {
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
ratingKey: '1',
globalKey: 's:1',
actionType: OfflineActionType.progress.id,
createdAt: now,
updatedAt: now,
),
);
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
ratingKey: '1',
globalKey: 's:1',
actionType: OfflineActionType.watched.id,
createdAt: now,
updatedAt: now + 100,
),
);
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
ratingKey: '2',
globalKey: 's:2',
actionType: OfflineActionType.unwatched.id,
createdAt: now,
updatedAt: now,
),
);
final result = await db.getLatestWatchActionsForKeys({'s:1', 's:2', 's:3-missing'});
expect(result.keys.toSet(), {'s:1', 's:2'});
expect(result['s:1']!.actionType, OfflineActionType.watched.id);
expect(result['s:2']!.actionType, OfflineActionType.unwatched.id);
});
test('getLatestWatchActionsForKeys filters by expected clientScopeId', () async {
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 'jf',
clientScopeId: const Value('jf/user-a'),
ratingKey: '1',
globalKey: 'jf:1',
actionType: OfflineActionType.unwatched.id,
createdAt: now,
updatedAt: now,
),
);
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 'jf',
clientScopeId: const Value('jf/user-b'),
ratingKey: '1',
globalKey: 'jf:1',
actionType: OfflineActionType.watched.id,
createdAt: now,
updatedAt: now + 100,
),
);
final userA = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-a'});
final userB = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-b'});
expect(userA['jf:1']!.actionType, OfflineActionType.unwatched.id);
expect(userB['jf:1']!.actionType, OfflineActionType.watched.id);
});
test('getLatestWatchActionsForKeys filters by profile when requested', () async {
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
profileId: const Value('profile-a'),
ratingKey: '1',
globalKey: 's:1',
actionType: OfflineActionType.unwatched.id,
createdAt: now,
updatedAt: now,
),
);
await db
.into(db.offlineWatchProgress)
.insert(
OfflineWatchProgressCompanion.insert(
serverId: 's',
profileId: const Value('profile-b'),
ratingKey: '1',
globalKey: 's:1',
actionType: OfflineActionType.watched.id,
createdAt: now,
updatedAt: now + 100,
),
);
final profileA = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-a', filterProfile: true);
final profileB = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-b', filterProfile: true);
final globalLatest = await db.getLatestWatchActionsForKeys({'s:1'});
expect(profileA['s:1']!.actionType, OfflineActionType.unwatched.id);
expect(profileB['s:1']!.actionType, OfflineActionType.watched.id);
expect(globalLatest['s:1']!.actionType, OfflineActionType.watched.id);
});
test('getLatestWatchActionsForKeys with empty input returns empty map (no query)', () async {
expect(await db.getLatestWatchActionsForKeys({}), isEmpty);
});
test('updateSyncAttempt increments syncAttempts and stores lastError', () async {
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
final inserted = (await db.select(db.offlineWatchProgress).get()).single;
await db.updateSyncAttempt(inserted.id, 'boom');
var row = (await db.select(db.offlineWatchProgress).get()).single;
expect(row.syncAttempts, 1);
expect(row.lastError, 'boom');
await db.updateSyncAttempt(inserted.id, null);
row = (await db.select(db.offlineWatchProgress).get()).single;
expect(row.syncAttempts, 2);
expect(row.lastError, isNull);
});
test('updateSyncAttempt is a no-op when id does not exist', () async {
await db.updateSyncAttempt(999, 'irrelevant');
expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
});
test('deleteWatchAction removes only the matching row', () async {
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.watched.id);
final rows = await db.select(db.offlineWatchProgress).get();
expect(rows, hasLength(2));
await db.deleteWatchAction(rows.first.id);
expect(await db.select(db.offlineWatchProgress).get(), hasLength(1));
});
test('getPendingSyncCount counts every row', () async {
expect(await db.getPendingSyncCount(), 0);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.unwatched.id);
expect(await db.getPendingSyncCount(), 2);
});
test('clearAllWatchActions empties the table', () async {
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '1', actionType: OfflineActionType.watched.id);
await db.insertWatchAction(serverId: ServerId('s'), ratingKey: '2', actionType: OfflineActionType.unwatched.id);
await db.clearAllWatchActions();
expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
});
});
}
void _registerSyncRulesTests() {
// ============================================================
// Sync Rules helpers
// ============================================================
group('SyncRules', () {
test('insertSyncRule + getSyncRules round-trip with defaults', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
final rules = await db.getSyncRules();
expect(rules, hasLength(1));
expect(rules.first.targetType, 'show');
expect(rules.first.profileId, '');
expect(rules.first.episodeCount, 5);
expect(rules.first.enabled, isTrue); // default
expect(rules.first.downloadFilter, 'unwatched'); // default
expect(rules.first.mediaIndex, 0); // default
expect(rules.first.includeSpecials, isTrue); // default
expect(rules.first.lastExecutedAt, isNull);
});
test('insertSyncRule upserts on the UNIQUE globalKey instead of crashing', () async {
// The auto-incremented primary key never collides, so a duplicate
// globalKey would fail the UNIQUE constraint with a vanilla
// `insertOnConflictUpdate`. The helper drives the upsert off
// [globalKey] so re-creating a rule for the same target updates the
// existing row rather than throwing.
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'season',
episodeCount: 99,
downloadFilter: 'all',
includeSpecials: false,
);
final rules = await db.getSyncRules();
expect(rules, hasLength(1));
expect(rules.first.targetType, 'season');
expect(rules.first.episodeCount, 99);
expect(rules.first.downloadFilter, 'all');
expect(rules.first.includeSpecials, isFalse);
});
test('insertSyncRule allows the same server item for different profiles', () async {
await db.insertSyncRule(
profileId: 'profile-a',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'profile-a|srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.insertSyncRule(
profileId: 'profile-b',
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'profile-b|srv:10',
targetType: 'show',
episodeCount: 9,
);
expect(await db.getSyncRules(profileId: 'profile-a'), hasLength(1));
expect((await db.getSyncRules(profileId: 'profile-a')).single.episodeCount, 5);
expect(await db.getSyncRules(profileId: 'profile-b'), hasLength(1));
expect((await db.getSyncRules(profileId: 'profile-b')).single.episodeCount, 9);
});
test('insertSyncRule preserves enabled + lastExecutedAt across upserts', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.updateSyncRuleEnabled('srv:10', false);
await db.updateSyncRuleLastExecuted('srv:10');
final firstRun = (await db.getSyncRule('srv:10'))!;
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 8,
);
final afterUpsert = (await db.getSyncRule('srv:10'))!;
expect(afterUpsert.episodeCount, 8);
expect(afterUpsert.enabled, isFalse, reason: 'upsert should preserve disabled flag');
expect(afterUpsert.lastExecutedAt, firstRun.lastExecutedAt);
});
test('getSyncRule returns the matching rule or null', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
expect(await db.getSyncRule('srv:10'), isNotNull);
expect(await db.getSyncRule('srv:nope'), isNull);
});
test('updateSyncRuleCount mutates only the count', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.updateSyncRuleCount('srv:10', 12);
final rule = await db.getSyncRule('srv:10');
expect(rule!.episodeCount, 12);
expect(rule.targetType, 'show'); // unchanged
});
test('updateSyncRuleFilter mutates the filter', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.updateSyncRuleFilter('srv:10', 'all');
final rule = await db.getSyncRule('srv:10');
expect(rule!.downloadFilter, 'all');
});
test('updateSyncRuleEnabled toggles enabled', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.updateSyncRuleEnabled('srv:10', false);
expect((await db.getSyncRule('srv:10'))!.enabled, isFalse);
await db.updateSyncRuleEnabled('srv:10', true);
expect((await db.getSyncRule('srv:10'))!.enabled, isTrue);
});
test('updateSyncRuleLastExecuted writes a timestamp', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
final before = DateTime.now().millisecondsSinceEpoch;
await db.updateSyncRuleLastExecuted('srv:10');
final after = DateTime.now().millisecondsSinceEpoch;
final rule = await db.getSyncRule('srv:10');
expect(rule!.lastExecutedAt, isNotNull);
expect(rule.lastExecutedAt! >= before, isTrue);
expect(rule.lastExecutedAt! <= after, isTrue);
});
test('deleteSyncRule removes the matching row', () async {
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '10',
globalKey: 'srv:10',
targetType: 'show',
episodeCount: 5,
);
await db.insertSyncRule(
serverId: ServerId('srv'),
ratingKey: '11',
globalKey: 'srv:11',
targetType: 'show',
episodeCount: 5,
);
await db.deleteSyncRule('srv:10');
final remaining = await db.getSyncRules();
expect(remaining, hasLength(1));
expect(remaining.first.globalKey, 'srv:11');
});
});
}
}
class _ThrowingDocumentsPathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
@override
Future<String?> getApplicationDocumentsPath() async => throw Exception('documents unavailable');
}