335940cf23
User intent (likes, hides, playlist adds, Lidarr requests, cancels) now persists across network loss. Controllers write their optimistic local state to drift first, then try the REST call; on failure the call is enqueued in cached_mutations rather than rolled back. MutationReplayer drains the queue on connectivity transitions and a 1-minute periodic tick. **Infrastructure (schema 8):** * CachedMutations table — id / kind / payload (JSON) / createdAt / lastAttemptAt / attempts. Drop-after-5-attempts semantics: a permanently-failing mutation eventually drops, and next sync reconciles drift to the server's authoritative state. * MutationQueue.enqueue / pendingCount * MutationReplayer.start + .drain — start fires from app.dart's postFrameCallback alongside SyncController / Prefetcher / etc. * Kind registry: like.add / like.remove / quarantine.flag / quarantine.unflag / playlist.append / request.create / request.cancel — each with a Ref+payload handler that re-fires the corresponding REST call. **Wired surfaces:** * LikesController.toggle — optimistic drift like/unlike stays across REST failure; queues the call. Drops the old rollback. * MyQuarantineController.flag / .unflag — same pattern. Hide/unhide visibly persists offline; replays when back online. * addToPlaylistActionProvider — now does an optimistic cached_playlist_tracks write (position = max + 1) so the playlist detail screen shows the new track instantly. Queues appendTracks on REST failure. * DiscoverScreen._request — queues request.create on DioException. No drift state for the request itself (myRequestsProvider is still REST-only) so the row won't show on /requests until replay succeeds — acceptable for v1. * MyRequestsController.cancel — optimistic in-memory remove no longer restores on failure; queues request.cancel instead. **Test update:** quarantine_provider_test "flag rolls back on server failure" renamed and rewritten to assert the new offline behavior: optimistic drift row persists, mutation is enqueued for replay. **Out of scope (v2):** * Playlist create / rename / delete (no Flutter UI exposes these yet) * Lidarr request optimistic local row (would need a cached_requests drift table) * UI "syncing N pending changes" indicator (operator preference: silent unless we find a concrete need)
149 lines
4.9 KiB
Dart
149 lines
4.9 KiB
Dart
@Tags(['drift'])
|
|
library;
|
|
|
|
import 'package:drift/native.dart' show NativeDatabase;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:minstrel/api/endpoints/quarantine.dart';
|
|
import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider;
|
|
import 'package:minstrel/cache/db.dart';
|
|
import 'package:minstrel/models/track.dart';
|
|
import 'package:minstrel/quarantine/quarantine_provider.dart';
|
|
|
|
// SKIP NOTE: drift's NativeDatabase needs the system libsqlite3.so.
|
|
// The flutter-ci runner image doesn't ship it. Same skip as
|
|
// sync_controller_test.dart — unblock by adding libsqlite3-dev to the
|
|
// CI image, or switch to sqlite3/wasm. On-device runs cover real
|
|
// behaviour today.
|
|
const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers';
|
|
|
|
class _StubQuarantineApi implements QuarantineApi {
|
|
_StubQuarantineApi({this.shouldThrow = false});
|
|
bool shouldThrow;
|
|
int flagCalls = 0;
|
|
int unflagCalls = 0;
|
|
|
|
@override
|
|
Future<void> flag(String trackId, String reason, {String notes = ''}) async {
|
|
flagCalls++;
|
|
if (shouldThrow) throw Exception('simulated server failure');
|
|
}
|
|
|
|
@override
|
|
Future<void> unflag(String trackId) async {
|
|
unflagCalls++;
|
|
if (shouldThrow) throw Exception('simulated server failure');
|
|
}
|
|
}
|
|
|
|
const _track = TrackRef(
|
|
id: 't1',
|
|
title: 'Roygbiv',
|
|
albumId: 'a1',
|
|
albumTitle: 'Geogaddi',
|
|
artistId: 'ar1',
|
|
artistName: 'Boards of Canada',
|
|
durationSec: 137,
|
|
trackNumber: 4,
|
|
streamUrl: '',
|
|
);
|
|
|
|
ProviderContainer _container({required AppDb db, required QuarantineApi api}) {
|
|
return ProviderContainer(overrides: [
|
|
appDbProvider.overrideWithValue(db),
|
|
quarantineApiProvider.overrideWith((ref) async => api),
|
|
]);
|
|
}
|
|
|
|
Future<void> _seed(AppDb db) async {
|
|
await db.into(db.cachedQuarantineMine).insert(
|
|
CachedQuarantineMineCompanion.insert(
|
|
trackId: 't1',
|
|
reason: 'bad_rip',
|
|
createdAt: '2026-05-01T00:00:00Z',
|
|
trackTitle: 'Roygbiv',
|
|
albumId: 'a1',
|
|
albumTitle: 'Geogaddi',
|
|
artistId: 'ar1',
|
|
artistName: 'Boards of Canada',
|
|
),
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
test('flag inserts a drift row and calls the server', skip: _skipDrift,
|
|
() async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
final api = _StubQuarantineApi();
|
|
final container = _container(db: db, api: api);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(myQuarantineProvider.future);
|
|
await container
|
|
.read(myQuarantineProvider.notifier)
|
|
.flag(_track, 'bad_rip', '');
|
|
|
|
expect(api.flagCalls, 1);
|
|
final rows = await db.select(db.cachedQuarantineMine).get();
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first.trackId, 't1');
|
|
expect(rows.first.reason, 'bad_rip');
|
|
});
|
|
|
|
test('flag keeps drift optimistic + queues mutation on server failure',
|
|
skip: _skipDrift, () async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
final api = _StubQuarantineApi(shouldThrow: true);
|
|
final container = _container(db: db, api: api);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(myQuarantineProvider.future);
|
|
// No longer rethrows — flag swallows the REST failure and queues
|
|
// for replay so the user's intent persists offline.
|
|
await container
|
|
.read(myQuarantineProvider.notifier)
|
|
.flag(_track, 'bad_rip', '');
|
|
final rows = await db.select(db.cachedQuarantineMine).get();
|
|
expect(rows, hasLength(1),
|
|
reason: 'optimistic drift row should persist across REST failure');
|
|
final mutations = await db.select(db.cachedMutations).get();
|
|
expect(mutations, hasLength(1),
|
|
reason: 'failed flag should have been enqueued for replay');
|
|
expect(mutations.first.kind, 'quarantine.flag');
|
|
});
|
|
|
|
test('unflag deletes the drift row and calls the server', skip: _skipDrift,
|
|
() async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
await _seed(db);
|
|
final api = _StubQuarantineApi();
|
|
final container = _container(db: db, api: api);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(myQuarantineProvider.future);
|
|
await container.read(myQuarantineProvider.notifier).unflag('t1');
|
|
|
|
expect(api.unflagCalls, 1);
|
|
final rows = await db.select(db.cachedQuarantineMine).get();
|
|
expect(rows, isEmpty);
|
|
});
|
|
|
|
test('isHidden reflects drift state', skip: _skipDrift, () async {
|
|
final db = AppDb(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
await _seed(db);
|
|
final api = _StubQuarantineApi();
|
|
final container = _container(db: db, api: api);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(myQuarantineProvider.future);
|
|
final ctrl = container.read(myQuarantineProvider.notifier);
|
|
expect(ctrl.isHidden('t1'), isTrue);
|
|
expect(ctrl.isHidden('t2'), isFalse);
|
|
});
|
|
}
|