0db7054518
Closes the cleanest remaining #356 gap — Discover submits Lidarr requests but had no surface for users to view or cancel their own. Admin had it; user didn't. - RequestsApi at lib/api/endpoints/requests.dart — listMine() + cancel(id). Same /api/requests endpoint the web /requests page uses; identical AdminRequest wire shape so the existing model is reused (just augmented with matched_*_id fields for the "Listen" CTA on completed rows). - MyRequestsController mirrors the web's auto-poll (#369 piece already shipped server-side / web-side): 12s refresh while any row is mid-ingest (status='approved'), stops on settle. Riverpod Timer in build() that's cancelled in onDispose. - RequestsScreen with kind/status pills, ingest progress copy, Cancel (with confirm dialog) and Listen CTAs per row state. - Route /requests under the shell. Entry point in settings between Profile and Appearance — "My requests". - Widget tests cover empty / pending / completed / rejected states + the Cancel-confirm dialog. Out of scope this slice: Discover-screen "View your requests" CTA after submitting a new request. Reasonable follow-up but doesn't block the management loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
3.9 KiB
Dart
129 lines
3.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:minstrel/auth/auth_provider.dart';
|
|
import 'package:minstrel/models/admin_request.dart';
|
|
import 'package:minstrel/models/user.dart';
|
|
import 'package:minstrel/requests/requests_provider.dart';
|
|
import 'package:minstrel/requests/requests_screen.dart';
|
|
import 'package:minstrel/theme/theme_data.dart';
|
|
|
|
class _StubAuth extends AuthController {
|
|
@override
|
|
Future<User?> build() async =>
|
|
const User(id: 'u1', username: 'alice', isAdmin: false);
|
|
}
|
|
|
|
class _StubRequests extends MyRequestsController {
|
|
_StubRequests(this._initial);
|
|
final List<AdminRequest> _initial;
|
|
@override
|
|
Future<List<AdminRequest>> build() async => _initial;
|
|
}
|
|
|
|
const _pendingAlbum = AdminRequest(
|
|
id: 'r1',
|
|
userId: 'u1',
|
|
status: 'pending',
|
|
kind: 'album',
|
|
artistName: 'Aphex Twin',
|
|
albumTitle: 'Drukqs',
|
|
trackTitle: null,
|
|
requestedAt: '2026-05-08T00:00:00Z',
|
|
decidedAt: null,
|
|
notes: null,
|
|
importedAlbumCount: 0,
|
|
importedTrackCount: 0,
|
|
);
|
|
|
|
const _completedTrackWithMatch = AdminRequest(
|
|
id: 'r2',
|
|
userId: 'u1',
|
|
status: 'completed',
|
|
kind: 'track',
|
|
artistName: 'Boards of Canada',
|
|
albumTitle: 'Geogaddi',
|
|
trackTitle: 'Roygbiv',
|
|
requestedAt: '2026-05-01T00:00:00Z',
|
|
decidedAt: '2026-05-02T00:00:00Z',
|
|
notes: null,
|
|
importedAlbumCount: 1,
|
|
importedTrackCount: 1,
|
|
matchedTrackId: 't1',
|
|
matchedAlbumId: 'a1',
|
|
);
|
|
|
|
const _rejectedWithNotes = AdminRequest(
|
|
id: 'r3',
|
|
userId: 'u1',
|
|
status: 'rejected',
|
|
kind: 'artist',
|
|
artistName: 'Some Artist',
|
|
albumTitle: null,
|
|
trackTitle: null,
|
|
requestedAt: '2026-05-01T00:00:00Z',
|
|
decidedAt: '2026-05-02T00:00:00Z',
|
|
notes: 'Not in MusicBrainz',
|
|
importedAlbumCount: 0,
|
|
importedTrackCount: 0,
|
|
);
|
|
|
|
Widget _harness(List<AdminRequest> requests) => ProviderScope(
|
|
overrides: [
|
|
authControllerProvider.overrideWith(() => _StubAuth()),
|
|
myRequestsProvider.overrideWith(() => _StubRequests(requests)),
|
|
],
|
|
child: MaterialApp(
|
|
theme: buildThemeData(),
|
|
home: const RequestsScreen(),
|
|
),
|
|
);
|
|
|
|
void main() {
|
|
testWidgets('renders empty state when no requests', (t) async {
|
|
await t.pumpWidget(_harness(const []));
|
|
await t.pumpAndSettle();
|
|
expect(find.text('Nothing requested yet.'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('renders pending row with Cancel CTA', (t) async {
|
|
await t.pumpWidget(_harness(const [_pendingAlbum]));
|
|
await t.pumpAndSettle();
|
|
expect(find.byKey(const Key('request_row_r1')), findsOneWidget);
|
|
expect(find.text('Drukqs'), findsOneWidget);
|
|
expect(find.text('Cancel'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('renders completed row with Listen CTA', (t) async {
|
|
await t.pumpWidget(_harness(const [_completedTrackWithMatch]));
|
|
await t.pumpAndSettle();
|
|
expect(find.text('Roygbiv'), findsOneWidget);
|
|
expect(find.text('Listen'), findsOneWidget);
|
|
// Ingest progress copy
|
|
expect(find.text('Track ingested'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('renders rejected row with notes; no CTA', (t) async {
|
|
await t.pumpWidget(_harness(const [_rejectedWithNotes]));
|
|
await t.pumpAndSettle();
|
|
expect(find.text('Some Artist'), findsOneWidget);
|
|
expect(find.text('Not in MusicBrainz'), findsOneWidget);
|
|
expect(find.text('Cancel'), findsNothing);
|
|
expect(find.text('Listen'), findsNothing);
|
|
});
|
|
|
|
testWidgets('Cancel button opens confirm dialog', (t) async {
|
|
await t.pumpWidget(_harness(const [_pendingAlbum]));
|
|
await t.pumpAndSettle();
|
|
await t.tap(find.text('Cancel'));
|
|
await t.pumpAndSettle();
|
|
expect(find.text('Cancel request?'), findsOneWidget);
|
|
expect(find.text('Cancel "Drukqs"?'), findsOneWidget);
|
|
// Dismiss with Keep
|
|
await t.tap(find.text('Keep'));
|
|
await t.pumpAndSettle();
|
|
expect(find.text('Cancel request?'), findsNothing);
|
|
});
|
|
}
|