feat(flutter/quarantine): MyQuarantineController + provider

AsyncNotifier wrapping /api/quarantine/mine with optimistic flag /
unflag mutations and an isHidden(trackId) convenience for menu state.
Mirrors the LikedIdsController pattern: optimistic update, rollback
on server error.

Used by the next slice's TrackActionsSheet to power Hide / Unhide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:39:32 -04:00
parent fdaa1a0472
commit 5159bcd3f4
2 changed files with 193 additions and 0 deletions
@@ -0,0 +1,123 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/api/endpoints/quarantine.dart';
import 'package:minstrel/models/quarantine_mine.dart';
import 'package:minstrel/models/track.dart';
import 'package:minstrel/quarantine/quarantine_provider.dart';
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');
}
}
class _StubController extends MyQuarantineController {
_StubController(this._initial);
final List<QuarantineMineRow> _initial;
@override
Future<List<QuarantineMineRow>> build() async => _initial;
}
const _track = TrackRef(
id: 't1',
title: 'Roygbiv',
albumId: 'a1',
albumTitle: 'Geogaddi',
artistId: 'ar1',
artistName: 'Boards of Canada',
durationSec: 137,
trackNumber: 4,
streamUrl: '',
);
const _existing = QuarantineMineRow(
trackId: 't1',
reason: 'bad_rip',
notes: null,
createdAt: '2026-05-01T00:00:00Z',
trackTitle: 'Roygbiv',
trackDurationMs: 137000,
albumId: 'a1',
albumTitle: 'Geogaddi',
artistId: 'ar1',
artistName: 'Boards of Canada',
);
void main() {
test('flag prepends synthetic row and calls server', () async {
final api = _StubQuarantineApi();
final container = ProviderContainer(overrides: [
quarantineApiProvider.overrideWith((ref) async => api),
myQuarantineProvider.overrideWith(() => _StubController(const [])),
]);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
await container.read(myQuarantineProvider.notifier).flag(_track, 'bad_rip', '');
expect(api.flagCalls, 1);
final list = container.read(myQuarantineProvider).value!;
expect(list, hasLength(1));
expect(list.first.trackId, 't1');
expect(list.first.reason, 'bad_rip');
});
test('flag rolls back on server failure', () async {
final api = _StubQuarantineApi(shouldThrow: true);
final container = ProviderContainer(overrides: [
quarantineApiProvider.overrideWith((ref) async => api),
myQuarantineProvider.overrideWith(() => _StubController(const [])),
]);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
await expectLater(
() => container
.read(myQuarantineProvider.notifier)
.flag(_track, 'bad_rip', ''),
throwsException,
);
expect(container.read(myQuarantineProvider).value, isEmpty);
});
test('unflag removes the row and calls server', () async {
final api = _StubQuarantineApi();
final container = ProviderContainer(overrides: [
quarantineApiProvider.overrideWith((ref) async => api),
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
]);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
await container.read(myQuarantineProvider.notifier).unflag('t1');
expect(api.unflagCalls, 1);
expect(container.read(myQuarantineProvider).value, isEmpty);
});
test('isHidden reflects current state', () async {
final container = ProviderContainer(overrides: [
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
]);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
final ctrl = container.read(myQuarantineProvider.notifier);
expect(ctrl.isHidden('t1'), isTrue);
expect(ctrl.isHidden('t2'), isFalse);
});
}