test: add provider and drift database tests
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Schema sanity
|
||||
// ============================================================
|
||||
|
||||
group('schema', () {
|
||||
test('schemaVersion is 13', () {
|
||||
expect(db.schemaVersion, 13);
|
||||
});
|
||||
|
||||
test('all tables are accessible and start empty', () async {
|
||||
expect(await db.select(db.downloadedMedia).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);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ApiCache: insert / select / update / delete round-trip
|
||||
// ============================================================
|
||||
|
||||
group('ApiCache', () {
|
||||
test('insert + select round-trip preserves fields', () async {
|
||||
await db
|
||||
.into(db.apiCache)
|
||||
.insert(ApiCacheCompanion.insert(cacheKey: 'srv:/library/metadata/1', data: '{"hello":"world"}'));
|
||||
|
||||
final rows = await db.select(db.apiCache).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.cacheKey, 'srv:/library/metadata/1');
|
||||
expect(rows.first.data, '{"hello":"world"}');
|
||||
expect(rows.first.pinned, isFalse); // default
|
||||
});
|
||||
|
||||
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>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('insertOnConflictUpdate replaces the row', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'first'));
|
||||
await db
|
||||
.into(db.apiCache)
|
||||
.insertOnConflictUpdate(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'second', pinned: const Value(true)));
|
||||
|
||||
final rows = await db.select(db.apiCache).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.data, 'second');
|
||||
expect(rows.first.pinned, isTrue);
|
||||
});
|
||||
|
||||
test('update modifies existing row', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k', data: 'orig'));
|
||||
await (db.update(
|
||||
db.apiCache,
|
||||
)..where((t) => t.cacheKey.equals('k'))).write(const ApiCacheCompanion(data: Value('updated')));
|
||||
|
||||
final row = await (db.select(db.apiCache)..where((t) => t.cacheKey.equals('k'))).getSingle();
|
||||
expect(row.data, 'updated');
|
||||
});
|
||||
|
||||
test('delete removes the row', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k', data: 'v'));
|
||||
expect(await db.select(db.apiCache).get(), hasLength(1));
|
||||
|
||||
await (db.delete(db.apiCache)..where((t) => t.cacheKey.equals('k'))).go();
|
||||
expect(await db.select(db.apiCache).get(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// DownloadedMedia: round-trip + helpers + update + delete
|
||||
// ============================================================
|
||||
|
||||
group('DownloadedMedia', () {
|
||||
Future<int> insertMovie({
|
||||
String serverId = 'srv1',
|
||||
String ratingKey = '100',
|
||||
int status = 0, // queued
|
||||
int progress = 0,
|
||||
}) async {
|
||||
return db
|
||||
.into(db.downloadedMedia)
|
||||
.insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
serverId: serverId,
|
||||
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.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('updating progress field works', () async {
|
||||
await insertMovie();
|
||||
await (db.update(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).write(
|
||||
const DownloadedMediaCompanion(progress: Value(75), downloadedBytes: Value(1024)),
|
||||
);
|
||||
|
||||
final row = await (db.select(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).getSingle();
|
||||
expect(row.progress, 75);
|
||||
expect(row.downloadedBytes, 1024);
|
||||
});
|
||||
|
||||
test('globalKey unique constraint blocks duplicate insert', () async {
|
||||
await insertMovie();
|
||||
expect(insertMovie(), throwsA(isA<Exception>()));
|
||||
});
|
||||
|
||||
test('delete removes only the matching row', () async {
|
||||
await insertMovie(ratingKey: '1');
|
||||
await insertMovie(ratingKey: '2');
|
||||
expect(await db.select(db.downloadedMedia).get(), hasLength(2));
|
||||
|
||||
await (db.delete(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:1'))).go();
|
||||
|
||||
final rows = await db.select(db.downloadedMedia).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.ratingKey, '2');
|
||||
});
|
||||
|
||||
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'});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// OfflineWatchProgress helpers
|
||||
// ============================================================
|
||||
|
||||
group('OfflineWatchProgress', () {
|
||||
test('upsertProgressAction inserts a new progress row', () async {
|
||||
await db.upsertProgressAction(
|
||||
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.actionType, OfflineActionType.progress.name);
|
||||
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: 'srv',
|
||||
ratingKey: '42',
|
||||
viewOffset: 1000,
|
||||
duration: 10000,
|
||||
shouldMarkWatched: false,
|
||||
);
|
||||
await db.upsertProgressAction(
|
||||
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('insertWatchAction (watched) clears prior progress + insert single row', () async {
|
||||
// Existing progress row for the same item
|
||||
await db.upsertProgressAction(
|
||||
serverId: 'srv',
|
||||
ratingKey: '42',
|
||||
viewOffset: 5000,
|
||||
duration: 10000,
|
||||
shouldMarkWatched: false,
|
||||
);
|
||||
|
||||
await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.name);
|
||||
|
||||
final rows = await db.select(db.offlineWatchProgress).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.actionType, OfflineActionType.watched.name);
|
||||
expect(rows.first.viewOffset, isNull);
|
||||
});
|
||||
|
||||
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.name,
|
||||
createdAt: now + 100,
|
||||
updatedAt: now + 100,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 's',
|
||||
ratingKey: '2',
|
||||
globalKey: 's:2',
|
||||
actionType: OfflineActionType.watched.name,
|
||||
createdAt: now + 50,
|
||||
updatedAt: now + 50,
|
||||
),
|
||||
);
|
||||
|
||||
final pending = await db.getPendingWatchActions();
|
||||
expect(pending.map((p) => p.ratingKey).toList(), ['2', '1']);
|
||||
});
|
||||
|
||||
test('getPendingWatchActionsForServer filters by serverId', () async {
|
||||
await db.insertWatchAction(serverId: 'a', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 'b', ratingKey: '2', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 'a', ratingKey: '3', actionType: OfflineActionType.unwatched.name);
|
||||
|
||||
final aRows = await db.getPendingWatchActionsForServer('a');
|
||||
expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'});
|
||||
|
||||
final bRows = await db.getPendingWatchActionsForServer('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.name,
|
||||
createdAt: now,
|
||||
updatedAt: now - 100,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 's',
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.watched.name,
|
||||
createdAt: now,
|
||||
updatedAt: now + 50,
|
||||
),
|
||||
);
|
||||
|
||||
final latest = await db.getLatestWatchAction('s:1');
|
||||
expect(latest, isNotNull);
|
||||
expect(latest!.actionType, OfflineActionType.watched.name);
|
||||
});
|
||||
|
||||
test('getLatestWatchAction returns null when no rows', () async {
|
||||
expect(await db.getLatestWatchAction('nope:nope'), isNull);
|
||||
});
|
||||
|
||||
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.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 's',
|
||||
ratingKey: '1',
|
||||
globalKey: 's:1',
|
||||
actionType: OfflineActionType.watched.name,
|
||||
createdAt: now,
|
||||
updatedAt: now + 100,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.offlineWatchProgress)
|
||||
.insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: 's',
|
||||
ratingKey: '2',
|
||||
globalKey: 's:2',
|
||||
actionType: OfflineActionType.unwatched.name,
|
||||
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.name);
|
||||
expect(result['s:2']!.actionType, OfflineActionType.unwatched.name);
|
||||
});
|
||||
|
||||
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: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
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: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.name);
|
||||
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: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name);
|
||||
expect(await db.getPendingSyncCount(), 2);
|
||||
});
|
||||
|
||||
test('clearAllWatchActions empties the table', () async {
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name);
|
||||
await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name);
|
||||
|
||||
await db.clearAllWatchActions();
|
||||
expect(await db.select(db.offlineWatchProgress).get(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Sync Rules helpers
|
||||
// ============================================================
|
||||
|
||||
group('SyncRules', () {
|
||||
test('insertSyncRule + getSyncRules round-trip with defaults', () async {
|
||||
await db.insertSyncRule(
|
||||
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.episodeCount, 5);
|
||||
expect(rules.first.enabled, isTrue); // default
|
||||
expect(rules.first.downloadFilter, 'unwatched'); // default
|
||||
expect(rules.first.mediaIndex, 0); // default
|
||||
expect(rules.first.lastExecutedAt, isNull);
|
||||
});
|
||||
|
||||
test('insertSyncRule with duplicate globalKey throws on the UNIQUE constraint', () async {
|
||||
// insertOnConflictUpdate only auto-targets the primary key (`id`), so a
|
||||
// collision on the UNIQUE `global_key` column still throws — this pins
|
||||
// current production behavior.
|
||||
await db.insertSyncRule(
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'srv:10',
|
||||
targetType: 'show',
|
||||
episodeCount: 5,
|
||||
);
|
||||
expect(
|
||||
() => db.insertSyncRule(
|
||||
serverId: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'srv:10',
|
||||
targetType: 'season',
|
||||
episodeCount: 99,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('getSyncRule returns the matching rule or null', () async {
|
||||
await db.insertSyncRule(
|
||||
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: '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: '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: '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: '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: 'srv',
|
||||
ratingKey: '10',
|
||||
globalKey: 'srv:10',
|
||||
targetType: 'show',
|
||||
episodeCount: 5,
|
||||
);
|
||||
await db.insertSyncRule(
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/database/download_operations.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// insertDownload
|
||||
// ============================================================
|
||||
|
||||
group('insertDownload', () {
|
||||
test('inserts a movie row with defaults', () async {
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
|
||||
final rows = await db.select(db.downloadedMedia).get();
|
||||
expect(rows, hasLength(1));
|
||||
final r = rows.first;
|
||||
expect(r.serverId, 'srv');
|
||||
expect(r.ratingKey, '100');
|
||||
expect(r.globalKey, 'srv:100');
|
||||
expect(r.type, 'movie');
|
||||
expect(r.status, DownloadStatus.queued.index);
|
||||
expect(r.parentRatingKey, isNull);
|
||||
expect(r.grandparentRatingKey, isNull);
|
||||
expect(r.mediaIndex, 0);
|
||||
});
|
||||
|
||||
test('inserts an episode with parent and grandparent keys', () async {
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: 'ep1',
|
||||
globalKey: 'srv:ep1',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.queued.index,
|
||||
mediaIndex: 7,
|
||||
);
|
||||
|
||||
final row = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(row.parentRatingKey, 'season1');
|
||||
expect(row.grandparentRatingKey, 'show1');
|
||||
expect(row.mediaIndex, 7);
|
||||
});
|
||||
|
||||
test('insertDownload uses InsertMode.insertOrReplace (re-insert overwrites)', () async {
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
// 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: 'srv',
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.failed.index,
|
||||
);
|
||||
|
||||
final row = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(row.status, DownloadStatus.failed.index);
|
||||
expect(row.progress, 0);
|
||||
expect(row.downloadedBytes, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Download queue + getNextQueueItem
|
||||
// ============================================================
|
||||
|
||||
group('queue', () {
|
||||
test('addToQueue inserts a row with defaults', () async {
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:100');
|
||||
|
||||
final rows = await db.select(db.downloadQueue).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.mediaGlobalKey, 'srv:100');
|
||||
expect(rows.first.priority, 0);
|
||||
expect(rows.first.downloadSubtitles, isTrue);
|
||||
expect(rows.first.downloadArtwork, isTrue);
|
||||
});
|
||||
|
||||
test('addToQueue stores custom priority and toggles', () async {
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:100', priority: 5, downloadSubtitles: false, downloadArtwork: false);
|
||||
|
||||
final row = (await db.select(db.downloadQueue).get()).single;
|
||||
expect(row.priority, 5);
|
||||
expect(row.downloadSubtitles, isFalse);
|
||||
expect(row.downloadArtwork, isFalse);
|
||||
});
|
||||
|
||||
test('addToQueue replaces row with same mediaGlobalKey (unique)', () async {
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:100', priority: 1);
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:100', priority: 9);
|
||||
|
||||
final rows = await db.select(db.downloadQueue).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.priority, 9);
|
||||
});
|
||||
|
||||
test('removeFromQueue deletes the matching row', () async {
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:1');
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:2');
|
||||
|
||||
await db.removeFromQueue('srv:1');
|
||||
|
||||
final rows = await db.select(db.downloadQueue).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.mediaGlobalKey, 'srv:2');
|
||||
});
|
||||
|
||||
test('getNextQueueItem returns null when empty', () async {
|
||||
expect(await db.getNextQueueItem(), isNull);
|
||||
});
|
||||
|
||||
test('getNextQueueItem only returns items whose media is queued', () async {
|
||||
// Two items in queue; one's media is still queued, the other is downloading.
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '1',
|
||||
globalKey: 'srv:1',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '2',
|
||||
globalKey: 'srv:2',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.downloading.index,
|
||||
);
|
||||
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:1', priority: 1);
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:2', priority: 10);
|
||||
|
||||
final next = await db.getNextQueueItem();
|
||||
expect(next, isNotNull);
|
||||
// Should pick srv:1 since srv:2 is downloading (not queued).
|
||||
expect(next!.mediaGlobalKey, 'srv:1');
|
||||
});
|
||||
|
||||
test('getNextQueueItem orders by priority desc, then addedAt asc', () async {
|
||||
// All have queued status
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '1',
|
||||
globalKey: 'srv:1',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '2',
|
||||
globalKey: 'srv:2',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '3',
|
||||
globalKey: 'srv:3',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
|
||||
// Manually inject deterministic addedAt so the test isn't time-dependent.
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await db
|
||||
.into(db.downloadQueue)
|
||||
.insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'srv:1', priority: const Value(1), addedAt: now));
|
||||
await db
|
||||
.into(db.downloadQueue)
|
||||
.insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'srv:2', priority: const Value(5), addedAt: now + 100));
|
||||
await db
|
||||
.into(db.downloadQueue)
|
||||
.insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'srv:3', priority: const Value(5), addedAt: now + 50));
|
||||
|
||||
final next = await db.getNextQueueItem();
|
||||
// priority 5 wins; srv:3 added before srv:2.
|
||||
expect(next!.mediaGlobalKey, 'srv:3');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Update helpers
|
||||
// ============================================================
|
||||
|
||||
group('update helpers', () {
|
||||
Future<void> seed({String key = 'srv:100'}) async {
|
||||
await db.insertDownload(
|
||||
serverId: key.split(':').first,
|
||||
ratingKey: key.split(':').last,
|
||||
globalKey: key,
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
}
|
||||
|
||||
test('updateDownloadStatus changes only status', () async {
|
||||
await seed();
|
||||
await db.updateDownloadStatus('srv:100', DownloadStatus.downloading.index);
|
||||
|
||||
final r = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(r.status, DownloadStatus.downloading.index);
|
||||
expect(r.progress, 0); // untouched
|
||||
});
|
||||
|
||||
test('updateDownloadProgress writes progress + bytes', () async {
|
||||
await seed();
|
||||
await db.updateDownloadProgress('srv:100', 42, 4242, 9999);
|
||||
|
||||
final r = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(r.progress, 42);
|
||||
expect(r.downloadedBytes, 4242);
|
||||
expect(r.totalBytes, 9999);
|
||||
});
|
||||
|
||||
test('updateVideoFilePath sets path and downloadedAt timestamp', () async {
|
||||
await seed();
|
||||
final before = DateTime.now().millisecondsSinceEpoch;
|
||||
await db.updateVideoFilePath('srv:100', '/tmp/file.mkv');
|
||||
final after = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final r = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(r.videoFilePath, '/tmp/file.mkv');
|
||||
expect(r.downloadedAt, isNotNull);
|
||||
expect(r.downloadedAt! >= before, isTrue);
|
||||
expect(r.downloadedAt! <= after, isTrue);
|
||||
});
|
||||
|
||||
test('updateArtworkPaths sets thumbPath; null clears it', () async {
|
||||
await seed();
|
||||
await db.updateArtworkPaths(globalKey: 'srv:100', thumbPath: '/tmp/thumb.jpg');
|
||||
expect((await db.select(db.downloadedMedia).get()).single.thumbPath, '/tmp/thumb.jpg');
|
||||
|
||||
await db.updateArtworkPaths(globalKey: 'srv:100', thumbPath: null);
|
||||
expect((await db.select(db.downloadedMedia).get()).single.thumbPath, isNull);
|
||||
});
|
||||
|
||||
test('updateDownloadError stores message and increments retryCount', () async {
|
||||
await seed();
|
||||
|
||||
await db.updateDownloadError('srv:100', 'first');
|
||||
var r = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(r.errorMessage, 'first');
|
||||
expect(r.retryCount, 1);
|
||||
|
||||
await db.updateDownloadError('srv:100', 'second');
|
||||
r = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(r.errorMessage, 'second');
|
||||
expect(r.retryCount, 2);
|
||||
});
|
||||
|
||||
test('clearDownloadError nulls the message and resets retryCount to 0', () async {
|
||||
await seed();
|
||||
await db.updateDownloadError('srv:100', 'oops');
|
||||
|
||||
await db.clearDownloadError('srv:100');
|
||||
final r = (await db.select(db.downloadedMedia).get()).single;
|
||||
expect(r.errorMessage, isNull);
|
||||
expect(r.retryCount, 0);
|
||||
});
|
||||
|
||||
test('updateBgTaskId / getBgTaskId round-trip', () async {
|
||||
await seed();
|
||||
expect(await db.getBgTaskId('srv:100'), isNull);
|
||||
|
||||
await db.updateBgTaskId('srv:100', 'task-abc');
|
||||
expect(await db.getBgTaskId('srv:100'), 'task-abc');
|
||||
|
||||
await db.updateBgTaskId('srv:100', null);
|
||||
expect(await db.getBgTaskId('srv:100'), isNull);
|
||||
});
|
||||
|
||||
test('getBgTaskId on missing globalKey returns null', () async {
|
||||
expect(await db.getBgTaskId('does:not-exist'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Lookup helpers
|
||||
// ============================================================
|
||||
|
||||
group('lookup helpers', () {
|
||||
Future<void> seedTree() async {
|
||||
await db.insertDownload(
|
||||
serverId: 'srvA',
|
||||
ratingKey: 'ep1',
|
||||
globalKey: 'srvA:ep1',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'srvA',
|
||||
ratingKey: 'ep2',
|
||||
globalKey: 'srvA:ep2',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season1',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'srvA',
|
||||
ratingKey: 'ep3',
|
||||
globalKey: 'srvA:ep3',
|
||||
type: 'episode',
|
||||
parentRatingKey: 'season2',
|
||||
grandparentRatingKey: 'show1',
|
||||
status: DownloadStatus.completed.index,
|
||||
);
|
||||
await db.insertDownload(
|
||||
serverId: 'srvB',
|
||||
ratingKey: 'movie1',
|
||||
globalKey: 'srvB:movie1',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
}
|
||||
|
||||
test('getDownloadedMedia returns the matching row or null', () async {
|
||||
await seedTree();
|
||||
|
||||
final hit = await db.getDownloadedMedia('srvA:ep1');
|
||||
expect(hit, isNotNull);
|
||||
expect(hit!.ratingKey, 'ep1');
|
||||
|
||||
expect(await db.getDownloadedMedia('nope:nope'), isNull);
|
||||
});
|
||||
|
||||
test('getEpisodesBySeason filters by parentRatingKey', () async {
|
||||
await seedTree();
|
||||
|
||||
final s1 = await db.getEpisodesBySeason('season1');
|
||||
expect(s1.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2'});
|
||||
|
||||
final s2 = await db.getEpisodesBySeason('season2');
|
||||
expect(s2.map((e) => e.ratingKey).toSet(), {'ep3'});
|
||||
|
||||
expect(await db.getEpisodesBySeason('seasonZ'), isEmpty);
|
||||
});
|
||||
|
||||
test('getEpisodesByShow filters by grandparentRatingKey', () async {
|
||||
await seedTree();
|
||||
|
||||
final all = await db.getEpisodesByShow('show1');
|
||||
expect(all.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2', 'ep3'});
|
||||
|
||||
expect(await db.getEpisodesByShow('show-missing'), isEmpty);
|
||||
});
|
||||
|
||||
test('getDownloadsByServerId filters by serverId', () async {
|
||||
await seedTree();
|
||||
|
||||
final a = await db.getDownloadsByServerId('srvA');
|
||||
expect(a.map((e) => e.ratingKey).toSet(), {'ep1', 'ep2', 'ep3'});
|
||||
|
||||
final b = await db.getDownloadsByServerId('srvB');
|
||||
expect(b.map((e) => e.ratingKey).toSet(), {'movie1'});
|
||||
|
||||
expect(await db.getDownloadsByServerId('srvZ'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// deleteDownload — removes from both tables
|
||||
// ============================================================
|
||||
|
||||
group('deleteDownload', () {
|
||||
test('removes the row from downloadedMedia AND its queue entry', () async {
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '100',
|
||||
globalKey: 'srv:100',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:100');
|
||||
await db.insertDownload(
|
||||
serverId: 'srv',
|
||||
ratingKey: '200',
|
||||
globalKey: 'srv:200',
|
||||
type: 'movie',
|
||||
status: DownloadStatus.queued.index,
|
||||
);
|
||||
await db.addToQueue(mediaGlobalKey: 'srv:200');
|
||||
|
||||
await db.deleteDownload('srv:100');
|
||||
|
||||
final media = await db.select(db.downloadedMedia).get();
|
||||
expect(media.map((m) => m.globalKey).toList(), ['srv:200']);
|
||||
|
||||
final queue = await db.select(db.downloadQueue).get();
|
||||
expect(queue.map((q) => q.mediaGlobalKey).toList(), ['srv:200']);
|
||||
});
|
||||
|
||||
test('deleteDownload on a missing globalKey is a no-op', () async {
|
||||
// Should not throw.
|
||||
await db.deleteDownload('nope:nope');
|
||||
expect(await db.select(db.downloadedMedia).get(), isEmpty);
|
||||
expect(await db.select(db.downloadQueue).get(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user