feat(train): signing in to Jellyfin and marking playlists (ADR 0030, D47)
Music's decisions as D47, and its first part: the music screen from the account sheet, on Android, signs in to the person's Jellyfin with Quick Connect, else a password, and lists their playlists of music to mark for running. The token is in the keyring, the server and the marks in the person's music.json, nothing on Train's server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5ff9b2e053
commit
3336fe4eca
3 changed files with 2364 additions and 3 deletions
|
|
@ -0,0 +1,263 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// Jellyfin as Train asks it (ADR 0030, D47): what the server is, signing in
|
||||
/// with Quick Connect or a password, and the person's playlists.
|
||||
void main() {
|
||||
final address = Uri.parse('https://music.example.org/jellyfin');
|
||||
|
||||
/// A Jellyfin server answering [routes] by path, as JSON; every request it
|
||||
/// saw in [seen].
|
||||
JellyfinClient serving(
|
||||
Map<String, (int, Object?)> routes, {
|
||||
List<http.Request>? seen,
|
||||
}) => JellyfinClient(
|
||||
MockClient((request) async {
|
||||
seen?.add(request);
|
||||
final route = routes['${request.method} ${request.url.path}'];
|
||||
if (route == null) {
|
||||
return http.Response('', 404);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode(route.$2),
|
||||
route.$1,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
deviceId: 'device-1',
|
||||
version: '0.1.0',
|
||||
);
|
||||
|
||||
group('an address as typed', () {
|
||||
test('becomes https without a scheme, keeps its path, drops a trailing '
|
||||
'slash', () {
|
||||
expect(
|
||||
jellyfinAddress(' music.example.org/jellyfin/ '),
|
||||
Uri.parse('https://music.example.org/jellyfin'),
|
||||
);
|
||||
expect(
|
||||
jellyfinAddress('http://192.168.1.4:8096'),
|
||||
Uri.parse('http://192.168.1.4:8096'),
|
||||
);
|
||||
});
|
||||
|
||||
test('is none without a host or with another scheme', () {
|
||||
expect(jellyfinAddress(''), isNull);
|
||||
expect(jellyfinAddress('ftp://music.example.org'), isNull);
|
||||
expect(jellyfinAddress('https://'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('a server says its name and whether Quick Connect is on', () async {
|
||||
final seen = <http.Request>[];
|
||||
final client = serving({
|
||||
'GET /jellyfin/System/Info/Public': (
|
||||
200,
|
||||
{'ServerName': 'Home', 'Id': 'server-1', 'Version': '10.10.7'},
|
||||
),
|
||||
'GET /jellyfin/QuickConnect/Enabled': (200, true),
|
||||
}, seen: seen);
|
||||
|
||||
final server = await client.server(address);
|
||||
|
||||
expect(server.name, 'Home');
|
||||
expect(server.id, 'server-1');
|
||||
expect(server.quickConnect, isTrue);
|
||||
expect(
|
||||
seen.first.headers['authorization'],
|
||||
'MediaBrowser Client="Halkyon Train", Device="Android", '
|
||||
'DeviceId="device-1", Version="0.1.0"',
|
||||
);
|
||||
});
|
||||
|
||||
test("what answers without Jellyfin's basics is not Jellyfin, and what "
|
||||
"doesn't answer is unreachable", () async {
|
||||
await expectLater(
|
||||
serving({
|
||||
'GET /jellyfin/System/Info/Public': (200, {'hello': 'world'}),
|
||||
}).server(address),
|
||||
throwsA(
|
||||
isA<JellyfinException>().having(
|
||||
(e) => e.failure,
|
||||
'failure',
|
||||
JellyfinFailure.notJellyfin,
|
||||
),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
serving({}).server(address),
|
||||
throwsA(
|
||||
isA<JellyfinException>().having(
|
||||
(e) => e.failure,
|
||||
'failure',
|
||||
JellyfinFailure.notJellyfin,
|
||||
),
|
||||
),
|
||||
);
|
||||
final down = JellyfinClient(
|
||||
MockClient((request) => throw http.ClientException('down')),
|
||||
deviceId: 'device-1',
|
||||
version: '0.1.0',
|
||||
);
|
||||
await expectLater(
|
||||
down.server(address),
|
||||
throwsA(
|
||||
isA<JellyfinException>().having(
|
||||
(e) => e.failure,
|
||||
'failure',
|
||||
JellyfinFailure.unreachable,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Quick Connect: a code to show, approved, then a token of its own',
|
||||
() async {
|
||||
final seen = <http.Request>[];
|
||||
var approved = false;
|
||||
final client = JellyfinClient(
|
||||
MockClient((request) async {
|
||||
seen.add(request);
|
||||
final body = switch ('${request.method} ${request.url.path}') {
|
||||
'POST /jellyfin/QuickConnect/Initiate' => {
|
||||
'Secret': 'secret-1',
|
||||
'Code': '123456',
|
||||
},
|
||||
'GET /jellyfin/QuickConnect/Connect' => {'Authenticated': approved},
|
||||
'POST /jellyfin/Users/AuthenticateWithQuickConnect' => {
|
||||
'AccessToken': 'token-1',
|
||||
'User': {'Id': 'user-1', 'Name': 'Ada'},
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
return http.Response(jsonEncode(body), body == null ? 404 : 200);
|
||||
}),
|
||||
deviceId: 'device-1',
|
||||
version: '0.1.0',
|
||||
);
|
||||
|
||||
final code = await client.initiateQuickConnect(address);
|
||||
expect(code.code, '123456');
|
||||
expect(await client.quickConnectApproved(address, code.secret), isFalse);
|
||||
approved = true;
|
||||
expect(await client.quickConnectApproved(address, code.secret), isTrue);
|
||||
final signedIn = await client.authenticateWithQuickConnect(
|
||||
address,
|
||||
code.secret,
|
||||
);
|
||||
|
||||
expect(signedIn, (token: 'token-1', userId: 'user-1', userName: 'Ada'));
|
||||
expect(seen[1].url.queryParameters, {'Secret': 'secret-1'});
|
||||
expect(jsonDecode(seen.last.body), {'Secret': 'secret-1'});
|
||||
},
|
||||
);
|
||||
|
||||
test('Quick Connect switched off is said so', () async {
|
||||
await expectLater(
|
||||
serving({
|
||||
'POST /jellyfin/QuickConnect/Initiate': (401, null),
|
||||
}).initiateQuickConnect(address),
|
||||
throwsA(
|
||||
isA<JellyfinException>().having(
|
||||
(e) => e.failure,
|
||||
'failure',
|
||||
JellyfinFailure.quickConnectOff,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a password signs in; a wrong one is refused', () async {
|
||||
final seen = <http.Request>[];
|
||||
final client = serving({
|
||||
'POST /jellyfin/Users/AuthenticateByName': (
|
||||
200,
|
||||
{
|
||||
'AccessToken': 'token-2',
|
||||
'User': {'Id': 'user-2', 'Name': 'Grace'},
|
||||
},
|
||||
),
|
||||
}, seen: seen);
|
||||
expect(
|
||||
await client.authenticateByName(address, 'Grace', 'pw'),
|
||||
(token: 'token-2', userId: 'user-2', userName: 'Grace'),
|
||||
);
|
||||
expect(jsonDecode(seen.single.body), {'Username': 'Grace', 'Pw': 'pw'});
|
||||
|
||||
await expectLater(
|
||||
serving({
|
||||
'POST /jellyfin/Users/AuthenticateByName': (401, null),
|
||||
}).authenticateByName(address, 'Grace', 'wrong'),
|
||||
throwsA(
|
||||
isA<JellyfinException>().having(
|
||||
(e) => e.failure,
|
||||
'failure',
|
||||
JellyfinFailure.refused,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"the person's playlists of music, with the token; videos' left out",
|
||||
() async {
|
||||
final seen = <http.Request>[];
|
||||
final client = serving({
|
||||
'GET /jellyfin/Items': (
|
||||
200,
|
||||
{
|
||||
'Items': [
|
||||
{
|
||||
'Id': 'p1',
|
||||
'Name': 'Tempo',
|
||||
'MediaType': 'Audio',
|
||||
'ChildCount': 42,
|
||||
},
|
||||
{'Id': 'p2', 'Name': 'Films', 'MediaType': 'Video'},
|
||||
{'Id': 'p3', 'Name': 'Old'},
|
||||
],
|
||||
},
|
||||
),
|
||||
}, seen: seen);
|
||||
const session = (token: 'token-1', userId: 'user-1');
|
||||
|
||||
final playlists = await client.playlists(address, session);
|
||||
|
||||
expect(playlists, const [
|
||||
JellyfinPlaylist(id: 'p1', name: 'Tempo', tracks: 42),
|
||||
JellyfinPlaylist(id: 'p3', name: 'Old'),
|
||||
]);
|
||||
expect(seen.single.url.queryParameters, {
|
||||
'userId': 'user-1',
|
||||
'includeItemTypes': 'Playlist',
|
||||
'recursive': 'true',
|
||||
'sortBy': 'SortName',
|
||||
'fields': 'ChildCount',
|
||||
});
|
||||
expect(
|
||||
seen.single.headers['authorization'],
|
||||
endsWith(', Token="token-1"'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a token Jellyfin no longer takes is refused', () async {
|
||||
await expectLater(
|
||||
serving({
|
||||
'GET /jellyfin/Items': (401, null),
|
||||
}).playlists(address, (token: 'old', userId: 'user-1')),
|
||||
throwsA(
|
||||
isA<JellyfinException>().having(
|
||||
(e) => e.failure,
|
||||
'failure',
|
||||
JellyfinFailure.refused,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||
|
||||
import '../../support/fake_jellyfin.dart';
|
||||
|
||||
/// The person's Jellyfin on this phone (ADR 0030, D47): signed in, the
|
||||
/// token in the keyring and nothing else of it in the file, the playlists
|
||||
/// marked, signed out.
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late File file;
|
||||
late FakeJellyfin jellyfin;
|
||||
late MemoryJellyfinTokenStore tokens;
|
||||
final address = Uri.parse('https://music.example.org');
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('music_');
|
||||
file = File('${folder.path}/music.json');
|
||||
jellyfin = FakeJellyfin();
|
||||
tokens = MemoryJellyfinTokenStore();
|
||||
});
|
||||
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
MusicLibrary open() => MusicLibrary(
|
||||
MusicStore(file),
|
||||
tokens,
|
||||
version: '0.1.0',
|
||||
client: jellyfin.client,
|
||||
);
|
||||
|
||||
test('a device ID is made once and kept, and Jellyfin is told it', () async {
|
||||
final first = open();
|
||||
final id = first.settings.value.deviceId;
|
||||
expect(id, hasLength(32));
|
||||
expect(open().settings.value.deviceId, id);
|
||||
|
||||
await first.find(address);
|
||||
expect(
|
||||
jellyfin.requests.first.headers['authorization'],
|
||||
contains('DeviceId="$id"'),
|
||||
);
|
||||
});
|
||||
|
||||
test('signed in with a password: the token in the keyring, never in the '
|
||||
'file; playlists marked and kept', () async {
|
||||
final library = open();
|
||||
final server = await library.find(address);
|
||||
await library.signInWithPassword(server, 'Ada', 'pw');
|
||||
|
||||
expect(tokens.token, 'token-1');
|
||||
expect(file.readAsStringSync(), isNot(contains('token-1')));
|
||||
expect(library.settings.value.signedIn, isTrue);
|
||||
expect(library.settings.value.userName, 'Ada');
|
||||
expect(library.settings.value.serverName, 'Home');
|
||||
|
||||
final playlists = await library.playlists();
|
||||
library.mark(playlists.first, on: true);
|
||||
expect(open().settings.value.playlists, {'p1': 'Tempo'});
|
||||
library.mark(playlists.first, on: false);
|
||||
expect(open().settings.value.playlists, isEmpty);
|
||||
});
|
||||
|
||||
test('a wrong password is refused, and nothing is kept', () async {
|
||||
final library = open();
|
||||
final server = await library.find(address);
|
||||
await expectLater(
|
||||
library.signInWithPassword(server, 'Ada', 'wrong'),
|
||||
throwsA(isA<JellyfinException>()),
|
||||
);
|
||||
expect(tokens.token, isNull);
|
||||
expect(library.settings.value.signedIn, isFalse);
|
||||
});
|
||||
|
||||
test('signing out ends the token on the server and forgets it and the '
|
||||
'playlists here', () async {
|
||||
final library = open();
|
||||
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
|
||||
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
|
||||
|
||||
await library.signOut();
|
||||
|
||||
expect(jellyfin.valid, isEmpty);
|
||||
expect(tokens.token, isNull);
|
||||
expect(library.settings.value.signedIn, isFalse);
|
||||
expect(library.settings.value.playlists, isEmpty);
|
||||
});
|
||||
|
||||
test('a token Jellyfin took back signs the person out here', () async {
|
||||
final library = open();
|
||||
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
|
||||
jellyfin.revoke();
|
||||
|
||||
await expectLater(library.playlists(), throwsA(isA<JellyfinException>()));
|
||||
expect(tokens.token, isNull);
|
||||
expect(library.settings.value.signedIn, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
"opening forgets others' tokens, and a sign-in whose token is gone",
|
||||
() async {
|
||||
final library = open();
|
||||
await library.signInWithPassword(
|
||||
await library.find(address),
|
||||
'Ada',
|
||||
'pw',
|
||||
);
|
||||
tokens.token = null;
|
||||
|
||||
final again = open();
|
||||
await again.open();
|
||||
|
||||
expect(tokens.others, isEmpty);
|
||||
expect(again.settings.value.signedIn, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test("a file that can't be read is none", () {
|
||||
file.writeAsStringSync('{not json');
|
||||
expect(open().settings.value.signedIn, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_jellyfin.dart';
|
||||
|
||||
/// The music screen (ADR 0030, D47): the server's address, Quick Connect's
|
||||
/// code until it is approved, else the password; then the playlists marked.
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late MemoryJellyfinTokenStore tokens;
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('music_');
|
||||
tokens = MemoryJellyfinTokenStore();
|
||||
});
|
||||
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
Future<MusicLibrary> show(WidgetTester tester, FakeJellyfin jellyfin) async {
|
||||
final library = MusicLibrary(
|
||||
MusicStore(File('${folder.path}/music.json')),
|
||||
tokens,
|
||||
version: '0.1.0',
|
||||
client: jellyfin.client,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('en'),
|
||||
home: BlocProvider(
|
||||
create: (context) => MusicCubit(
|
||||
library,
|
||||
poll: const Duration(milliseconds: 100),
|
||||
)..open(),
|
||||
child: const MusicScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pumpAndSettle();
|
||||
return library;
|
||||
}
|
||||
|
||||
/// Lets the fake server's answers and the screen's timers run.
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('Quick Connect: the code shown until approved, then the '
|
||||
"person's playlists, one marked", (tester) async {
|
||||
final jellyfin = FakeJellyfin();
|
||||
final library = await show(tester, jellyfin);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'music.example.org');
|
||||
await tester.tap(find.text('Connect'));
|
||||
await settle(tester);
|
||||
expect(find.text('123456'), findsOneWidget);
|
||||
expect(find.textContaining('To sign in to Home'), findsOneWidget);
|
||||
|
||||
jellyfin.approve();
|
||||
await settle(tester);
|
||||
expect(find.text('Signed in as Ada'), findsOneWidget);
|
||||
expect(find.text('Tempo'), findsOneWidget);
|
||||
expect(find.text('42 tracks'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Tempo'));
|
||||
await tester.pump();
|
||||
expect(library.settings.value.playlists, {'p1': 'Tempo'});
|
||||
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await settle(tester);
|
||||
expect(find.text('Jellyfin server'), findsOneWidget);
|
||||
expect(tokens.token, isNull);
|
||||
});
|
||||
|
||||
testWidgets('without Quick Connect, the password; a wrong one said so', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, FakeJellyfin(quickConnect: false));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'music.example.org');
|
||||
await tester.tap(find.text('Connect'));
|
||||
await settle(tester);
|
||||
expect(find.text('User name'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Ada');
|
||||
await tester.enterText(find.byType(TextField).last, 'wrong');
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await settle(tester);
|
||||
expect(find.textContaining('Jellyfin refused'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField).last, 'pw');
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await settle(tester);
|
||||
expect(find.text('Signed in as Ada'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an address that names no server is said so, and nothing is '
|
||||
'asked', (tester) async {
|
||||
final jellyfin = FakeJellyfin();
|
||||
await show(tester, jellyfin);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ftp://x');
|
||||
await tester.tap(find.text('Connect'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining("Enter the server's address"), findsOneWidget);
|
||||
expect(jellyfin.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a code not approved in time expires, and a new one is asked '
|
||||
'for', (tester) async {
|
||||
final jellyfin = FakeJellyfin();
|
||||
final library = MusicLibrary(
|
||||
MusicStore(File('${folder.path}/music.json')),
|
||||
tokens,
|
||||
version: '0.1.0',
|
||||
client: jellyfin.client,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('en'),
|
||||
home: BlocProvider(
|
||||
create: (context) => MusicCubit(
|
||||
library,
|
||||
poll: const Duration(milliseconds: 100),
|
||||
expiry: Duration.zero,
|
||||
)..open(),
|
||||
child: const MusicScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'music.example.org');
|
||||
await tester.tap(find.text('Connect'));
|
||||
await settle(tester);
|
||||
expect(find.text('The code has expired.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('New code'));
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pump();
|
||||
expect(find.text('123456'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue