halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set on halkyon_base's client: the instances a device keeps an account on, the first start's tiles and where an instance keeps people's data, the account switcher; signing in anonymously, with GitHub or an email code, switching person and the start screen; the gates that build an app's dependencies per instance and per person; and the sync service with its status, Settings card and banner. Texts of its own in English and German take the app's name; what only the app can say, it passes in. Fakes for apps' tests are in testing.dart. An instance's basics now name the app it serves (`app`, set by the host's configuration), and an app accepts only an instance of its own. SerialQueue and CoalescingRunner move into halkyon_core. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
af1de799f4
commit
e23cc77f7c
26 changed files with 10761 additions and 10 deletions
275
packages/halkyon_app/test/app/instance_gate_test.dart
Normal file
275
packages/halkyon_app/test/app/instance_gate_test.dart
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import 'package:flag/flag.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A signed-in person's app as a test makes one: who it is for, and the
|
||||
/// way to Switch account.
|
||||
class const _PersonApp(final SignedIn person) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
localizationsDelegates: testLocalizationsDelegates,
|
||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Column(
|
||||
children: [
|
||||
Text('Home of ${person.authUserId}'),
|
||||
TextButton(
|
||||
onPressed: () => showAccountSwitcher(context),
|
||||
child: const Text('Switch account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final builtIn = Instance.parse('learn.halkyon.eu')!;
|
||||
final other = Instance.parse('learn.example.org')!;
|
||||
late MemoryInstanceStore store;
|
||||
late List<Instance> opened;
|
||||
late List<TestAppDependencies> dependencies;
|
||||
late List<Instance> forgotten;
|
||||
|
||||
setUp(() {
|
||||
store = MemoryInstanceStore();
|
||||
opened = [];
|
||||
dependencies = [];
|
||||
forgotten = [];
|
||||
});
|
||||
|
||||
/// Starts the app on a device that [usedBefore] the choice existed, with
|
||||
/// [answering] instances answering a check, saying where they keep data
|
||||
/// as [locations] has it, and the account on each [signedOut].
|
||||
Future<InstanceChoice> start(
|
||||
WidgetTester tester, {
|
||||
bool usedBefore = false,
|
||||
bool signedOut = false,
|
||||
Set<Instance> answering = const {},
|
||||
Map<Instance, InstanceLocations> locations = const {},
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(412, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final choice = InstanceChoice(
|
||||
store,
|
||||
builtIn: builtIn,
|
||||
forget: (instance) async => forgotten.add(instance),
|
||||
);
|
||||
await choice.load(usedBefore: usedBefore);
|
||||
await tester.pumpWidget(
|
||||
InstanceGate(
|
||||
frame: testFrame(),
|
||||
choice: choice,
|
||||
checker: FakeInstanceChecker(answering, locations),
|
||||
countryNames: MemoryCountryNames(const {
|
||||
'CH': {'en': 'Switzerland'},
|
||||
}),
|
||||
readLook: () async => (themeMode: ThemeMode.light, locale: null),
|
||||
open: (instance) async {
|
||||
opened.add(instance);
|
||||
final opening = TestAppDependencies(serverName: instance.name);
|
||||
await opening.signedOut.write(signedOut: signedOut);
|
||||
dependencies.add(opening);
|
||||
return opening;
|
||||
},
|
||||
app: SessionGate(
|
||||
look: (context) => (themeMode: ThemeMode.light, locale: null),
|
||||
app: _PersonApp.new,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return choice;
|
||||
}
|
||||
|
||||
Future<void> openSwitcher(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Switch account'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a new install asks for the instance first, and opens the '
|
||||
"build's in one tap", (tester) async {
|
||||
await start(tester);
|
||||
|
||||
// The wordmark says it to screen readers, naming the app.
|
||||
expect(find.bySemanticsLabel('Welcome to Halkyon Test'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Choose the instance to keep your notes on.'),
|
||||
findsOneWidget,
|
||||
reason: "the app's own words",
|
||||
);
|
||||
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
|
||||
expect(find.byType(TextField), findsNothing, reason: 'no address asked');
|
||||
|
||||
await tester.tap(find.text('learn.halkyon.eu'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(opened, [builtIn]);
|
||||
expect(find.text('Home of anonymous-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("the build's instance shows where it keeps people's data, "
|
||||
'once it says', (tester) async {
|
||||
await start(
|
||||
tester,
|
||||
answering: {builtIn},
|
||||
locations: {
|
||||
builtIn: const InstanceLocations(data: 'EU', backups: 'CH'),
|
||||
},
|
||||
);
|
||||
|
||||
expect(find.text('Data: European Union\nBackups: Switzerland'), findsOne);
|
||||
expect(find.byType(Flag), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('an instance saying nothing, or not answering, comes with the '
|
||||
'app, without a flag', (tester) async {
|
||||
await start(tester);
|
||||
|
||||
expect(find.text('Comes with the app'), findsOne);
|
||||
expect(find.byType(Flag), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('another instance is checked before the app moves there', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, answering: {other});
|
||||
|
||||
await tester.tap(find.text('Another instance'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
FocusManager.instance.primaryFocus?.context
|
||||
?.findAncestorWidgetOfExactType<TextField>(),
|
||||
isNotNull,
|
||||
reason: 'the address is typed at once',
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(TextField, 'test.example.org'),
|
||||
findsOneWidget,
|
||||
reason: "the app's example address as the hint",
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'nowhere.example.org');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('No instance of Halkyon Test answers at nowhere.example.org.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(opened, isEmpty);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'learn.example.org');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(opened, [other]);
|
||||
});
|
||||
|
||||
testWidgets('an install from before goes on without asking', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
|
||||
expect(opened, [builtIn]);
|
||||
expect(find.text('Home of anonymous-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Switch account moves between the accounts on the device, '
|
||||
'closing the app on the one before, and removes one not in '
|
||||
'use', (tester) async {
|
||||
store = MemoryInstanceStore((current: builtIn, known: [builtIn, other]));
|
||||
await start(
|
||||
tester,
|
||||
answering: {builtIn, other},
|
||||
locations: {
|
||||
other: const InstanceLocations(
|
||||
data: 'CH',
|
||||
backups: InstanceLocations.noBackups,
|
||||
),
|
||||
},
|
||||
);
|
||||
await openSwitcher(tester);
|
||||
expect(find.text('learn.halkyon.eu'), findsWidgets);
|
||||
// Each account with where its instance says it keeps people's data.
|
||||
expect(find.text('Data: Switzerland\nNo backups'), findsOne);
|
||||
expect(find.byType(Flag), findsOne);
|
||||
expect(find.text('Anonymous'), findsOne, reason: 'the account in use');
|
||||
await tester.tap(find.text('learn.example.org'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(opened, [builtIn, other]);
|
||||
expect(dependencies.first.disposed, isTrue);
|
||||
expect(find.text('Home of anonymous-1'), findsOneWidget);
|
||||
|
||||
await openSwitcher(tester);
|
||||
await tester.tap(find.byTooltip('Remove account'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(forgotten, [builtIn]);
|
||||
expect(store.kept?.known, [other]);
|
||||
});
|
||||
|
||||
testWidgets('signed out, the start screen names the instance and offers '
|
||||
'another, through Switch account', (tester) async {
|
||||
store = MemoryInstanceStore((current: builtIn, known: [builtIn]));
|
||||
await start(tester, answering: {builtIn, other}, signedOut: true);
|
||||
|
||||
expect(find.text('Start on learn.halkyon.eu:'), findsOneWidget);
|
||||
await tester.tap(find.text('Use another instance'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add account'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Choose the instance for the account to add.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'learn.example.org');
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(opened, [builtIn, other]);
|
||||
expect(find.text('Start on learn.example.org:'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("an instance whose app doesn't open says so, and tries "
|
||||
'again', (tester) async {
|
||||
store = MemoryInstanceStore((current: builtIn, known: [builtIn]));
|
||||
final choice = InstanceChoice(
|
||||
store,
|
||||
builtIn: builtIn,
|
||||
forget: (instance) async {},
|
||||
);
|
||||
await choice.load(usedBefore: false);
|
||||
var attempts = 0;
|
||||
await tester.pumpWidget(
|
||||
InstanceGate(
|
||||
frame: testFrame(),
|
||||
choice: choice,
|
||||
checker: FakeInstanceChecker(),
|
||||
countryNames: MemoryCountryNames(const {}),
|
||||
readLook: () async => (themeMode: ThemeMode.light, locale: null),
|
||||
open: (instance) async {
|
||||
if (++attempts == 1) {
|
||||
throw StateError('disk unavailable');
|
||||
}
|
||||
return TestAppDependencies();
|
||||
},
|
||||
app: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Text('the app'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Couldn't open the app on learn.halkyon.eu."), findsOne);
|
||||
|
||||
await tester.tap(find.text('Try again'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('the app'), findsOne);
|
||||
});
|
||||
}
|
||||
55
packages/halkyon_app/test/app/person_scope_test.dart
Normal file
55
packages/halkyon_app/test/app/person_scope_test.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What the person sees: whose dependencies are open above it.
|
||||
class const _Person() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Text('Data of ${PersonScope.of<TestPerson>(context).person.authUserId}');
|
||||
}
|
||||
|
||||
void main() {
|
||||
const ada = SignedIn(authUserId: 'ada', isIdentified: true);
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
setUp(() => dependencies = TestAppDependencies());
|
||||
|
||||
Future<void> show(WidgetTester tester, Widget scope) async {
|
||||
await tester.pumpWidget(testApp(scope, dependencies: dependencies));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("shows the person's app once their data is open", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, const PersonScope(person: ada, child: _Person()));
|
||||
|
||||
expect(find.text('Data of ada'), findsOneWidget);
|
||||
expect(dependencies.opened.single.person, ada);
|
||||
});
|
||||
|
||||
testWidgets("says so when the person's data can't be opened, and offers "
|
||||
'to try again; an attempt that works shows the app', (tester) async {
|
||||
dependencies.openingError = Exception('database locked');
|
||||
await show(tester, const PersonScope(person: ada, child: _Person()));
|
||||
|
||||
expect(find.text("Couldn't open this device's data."), findsOneWidget);
|
||||
expect(find.text('Data of ada'), findsNothing);
|
||||
|
||||
dependencies.openingError = null;
|
||||
await tester.tap(find.text('Try again'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Data of ada'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("closes the person's data when it goes away", (tester) async {
|
||||
await show(tester, const PersonScope(person: ada, child: _Person()));
|
||||
|
||||
await show(tester, const Text('Someone else'));
|
||||
|
||||
expect(dependencies.opened.single.disposed, isTrue);
|
||||
});
|
||||
}
|
||||
242
packages/halkyon_app/test/auth/bloc/email_sign_in_bloc_test.dart
Normal file
242
packages/halkyon_app/test/auth/bloc/email_sign_in_bloc_test.dart
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
void main() {
|
||||
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
|
||||
|
||||
late FakeAuthGateway gateway;
|
||||
late MemorySignInAddresses addresses;
|
||||
|
||||
/// Whether this device holds content a switch would leave behind.
|
||||
late bool holdsContent;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(status: anonymous);
|
||||
addresses = MemorySignInAddresses();
|
||||
holdsContent = false;
|
||||
});
|
||||
|
||||
EmailSignInBloc newBloc({String language = 'en'}) {
|
||||
final bloc = EmailSignInBloc(
|
||||
AuthRepository(gateway, addresses),
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
language: language,
|
||||
);
|
||||
addTearDown(bloc.close);
|
||||
return bloc;
|
||||
}
|
||||
|
||||
/// A bloc waiting for the code mailed to [address].
|
||||
Future<EmailSignInBloc> codeMailed(String address) async {
|
||||
final bloc = newBloc()..add(EmailSignInEventCodeRequested(address));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(isA<EmailSignInStateCode>()),
|
||||
);
|
||||
return bloc;
|
||||
}
|
||||
|
||||
test('starts at the address', () {
|
||||
expect(newBloc().state, const EmailSignInStateAddress());
|
||||
});
|
||||
|
||||
test("mails a code in the app's language, and the code makes the address "
|
||||
"the person's: they keep everything and become identified", () async {
|
||||
final bloc = newBloc(language: 'de')
|
||||
..add(const EmailSignInEventCodeRequested(' Anna@Example.org '));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateAddress(sending: true),
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(gateway.mailed.single.language, 'de');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('code-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
checking: true,
|
||||
),
|
||||
const EmailSignInStateLinked(address: 'anna@example.org'),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
expect(addresses.addresses, {'anonymous-1': 'anna@example.org'});
|
||||
});
|
||||
|
||||
test('says what is wrong with an address, and with a code, and takes the '
|
||||
'next try', () async {
|
||||
final bloc = newBloc()..add(const EmailSignInEventCodeRequested('anna'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateAddress(sending: true),
|
||||
const EmailSignInStateAddress(
|
||||
problem: EmailSignInProblem.notAnAddress,
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
bloc.add(const EmailSignInEventCodeRequested('anna@example.org'));
|
||||
await expectLater(bloc.stream, emitsThrough(isA<EmailSignInStateCode>()));
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-9'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
problem: EmailSignInProblem.wrongCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(const EmailSignInStateLinked(address: 'anna@example.org')),
|
||||
);
|
||||
});
|
||||
|
||||
test('mails a new code on request, and goes back to the address', () async {
|
||||
final bloc = await codeMailed('anna@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeResent());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
checking: true,
|
||||
),
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-2',
|
||||
resent: true,
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
bloc.add(const EmailSignInEventAddressReopened());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const EmailSignInStateAddress()),
|
||||
);
|
||||
});
|
||||
|
||||
test("another person's address: a device holding nothing switches to "
|
||||
'them straight away', () async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
final bloc = await codeMailed('ben@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(const EmailSignInStateSwitching()),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.emailSwitches, 1);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'ben', isIdentified: true),
|
||||
);
|
||||
expect(addresses.addresses, {'ben': 'ben@example.org'});
|
||||
});
|
||||
|
||||
test('a device holding content asks first: declining changes nothing, '
|
||||
'confirming switches', () async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
holdsContent = true;
|
||||
final bloc = await codeMailed('ben@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const EmailSignInStateConfirmSwitch(
|
||||
address: 'ben@example.org',
|
||||
switchToken: 'switch:ben@example.org',
|
||||
),
|
||||
),
|
||||
);
|
||||
bloc.add(const EmailSignInEventSwitchDeclined());
|
||||
await expectLater(bloc.stream, emits(const EmailSignInStateAddress()));
|
||||
expect(gateway.emailSwitches, 0);
|
||||
expect(gateway.status, anonymous);
|
||||
|
||||
bloc.add(const EmailSignInEventCodeRequested('ben@example.org'));
|
||||
await expectLater(bloc.stream, emitsThrough(isA<EmailSignInStateCode>()));
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-2'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(isA<EmailSignInStateConfirmSwitch>()),
|
||||
);
|
||||
bloc.add(const EmailSignInEventSwitchConfirmed());
|
||||
await expectLater(bloc.stream, emits(const EmailSignInStateSwitching()));
|
||||
await pumpEventQueue();
|
||||
expect(gateway.emailSwitches, 1);
|
||||
});
|
||||
|
||||
test("someone identified whose address is another person's is told so, "
|
||||
'never switched: their own account stays theirs', () async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
)..addressOwners['ben@example.org'] = 'ben';
|
||||
final bloc = await codeMailed('ben@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(const EmailSignInStateTaken(address: 'ben@example.org')),
|
||||
);
|
||||
expect(gateway.emailSwitches, 0);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
test("says when the instance couldn't send the mail", () async {
|
||||
gateway.emailError = const EmailSignInFailed(
|
||||
EmailSignInProblem.mailFailed,
|
||||
);
|
||||
final bloc = newBloc()
|
||||
..add(const EmailSignInEventCodeRequested('anna@example.org'));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateAddress(sending: true),
|
||||
const EmailSignInStateAddress(problem: EmailSignInProblem.mailFailed),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("says when the instance can't be reached", () async {
|
||||
gateway.emailError = Exception('offline');
|
||||
final bloc = newBloc()
|
||||
..add(const EmailSignInEventCodeRequested('anna@example.org'));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateAddress(sending: true),
|
||||
const EmailSignInStateAddress(problem: EmailSignInProblem.failed),
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
void main() {
|
||||
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
|
||||
|
||||
late FakeAuthGateway gateway;
|
||||
|
||||
/// Whether this device holds content a switch would leave behind.
|
||||
late bool holdsContent;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(status: anonymous);
|
||||
holdsContent = false;
|
||||
});
|
||||
|
||||
/// A bloc as the account dialog starts it, once it knows whether this
|
||||
/// instance offers GitHub sign-in.
|
||||
Future<GitHubSignInBloc> ready() async {
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
return bloc;
|
||||
}
|
||||
|
||||
test('offers nothing where the instance has no GitHub sign-in', () async {
|
||||
gateway.offersGitHub = false;
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable()));
|
||||
});
|
||||
|
||||
test('links the GitHub account to the person', () async {
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateLinked(),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('changes nothing when the person cancels on GitHub', () async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.cancelled;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
|
||||
test(
|
||||
"the person can cancel while GitHub's page is open; nothing changes",
|
||||
() async {
|
||||
gateway
|
||||
..canCancelGitHub = true
|
||||
..gitHubPageOpens = true;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||
);
|
||||
bloc.add(const GitHubSignInEventCancelled());
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
expect(gateway.gitHubCancels, 1);
|
||||
expect(gateway.status, anonymous);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"offers no cancel where the system's sign-in screen has its own",
|
||||
() async {
|
||||
gateway.gitHubPageOpens = true;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<GitHubSignInStateBusy>().having(
|
||||
(state) => state.cancellable,
|
||||
'cancellable',
|
||||
isFalse,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"closing the dialog while GitHub's page is open cancels the sign-in",
|
||||
() async {
|
||||
gateway
|
||||
..canCancelGitHub = true
|
||||
..gitHubPageOpens = true;
|
||||
final bloc = await ready();
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||
);
|
||||
|
||||
await bloc.close();
|
||||
|
||||
expect(gateway.gitHubCancels, 1);
|
||||
expect(gateway.status, anonymous);
|
||||
},
|
||||
);
|
||||
|
||||
test('closing the dialog otherwise cancels nothing', () async {
|
||||
final bloc = await ready();
|
||||
|
||||
await bloc.close();
|
||||
|
||||
expect(gateway.gitHubCancels, 0);
|
||||
});
|
||||
|
||||
test("switches right away when the GitHub account is someone else's and "
|
||||
'this device holds nothing', () async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.gitHubSwitches, 1);
|
||||
});
|
||||
|
||||
test("someone identified whose GitHub account is another person's is told "
|
||||
'so, never switched: their own account stays theirs', () async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
)..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateTaken(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
group("when the GitHub account is someone else's and this device holds "
|
||||
'content', () {
|
||||
late GitHubSignInBloc bloc;
|
||||
|
||||
setUp(() async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
holdsContent = true;
|
||||
bloc = (await ready())..add(const GitHubSignInEventRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateConfirmSwitch(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('asks first; staying anonymous changes nothing', () async {
|
||||
bloc.add(const GitHubSignInEventSwitchDeclined());
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
|
||||
test('switches once confirmed', () async {
|
||||
bloc.add(const GitHubSignInEventSwitchConfirmed());
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.gitHubSwitches, 1);
|
||||
});
|
||||
|
||||
test("cancelling GitHub's page for the switch stays anonymous", () async {
|
||||
gateway
|
||||
..canCancelGitHub = true
|
||||
..gitHubPageOpens = true;
|
||||
|
||||
bloc.add(const GitHubSignInEventSwitchConfirmed());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const GitHubSignInStateBusy(cancellable: true)),
|
||||
);
|
||||
bloc.add(const GitHubSignInEventCancelled());
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
});
|
||||
|
||||
test('says so when signing in fails', () async {
|
||||
final bloc = await ready();
|
||||
gateway.gitHubError = Exception('No connection');
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateReady(failed: true),
|
||||
]),
|
||||
);
|
||||
expect(gateway.status, anonymous);
|
||||
});
|
||||
}
|
||||
206
packages/halkyon_app/test/auth/bloc/session_bloc_test.dart
Normal file
206
packages/halkyon_app/test/auth/bloc/session_bloc_test.dart
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late MemorySignedOutMark mark;
|
||||
late SessionBloc bloc;
|
||||
|
||||
SessionStateActive anonymous(int number) => SessionStateActive(
|
||||
SignedIn(authUserId: 'anonymous-$number', isIdentified: false),
|
||||
);
|
||||
|
||||
void start({AuthStatus status = const SignedOut(), bool signedOut = false}) {
|
||||
gateway = FakeAuthGateway(status: status);
|
||||
mark = MemorySignedOutMark(signedOut);
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark);
|
||||
}
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
test('signs in anonymously at a first start, nobody having signed out '
|
||||
'here', () async {
|
||||
start();
|
||||
|
||||
bloc.add(const SessionEventStarted());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
|
||||
);
|
||||
expect(gateway.signIns, 1);
|
||||
});
|
||||
|
||||
test('keeps a stored session instead of creating a second account', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
start(status: stored);
|
||||
|
||||
bloc.add(const SessionEventStarted());
|
||||
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('reports a failed sign-in and signs in when asked again', () async {
|
||||
start();
|
||||
gateway.signInError = Exception('No connection');
|
||||
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), const SessionStateFailed()]),
|
||||
);
|
||||
|
||||
gateway.signInError = null;
|
||||
bloc.add(const SessionEventSignInRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(2)]),
|
||||
);
|
||||
});
|
||||
|
||||
test('after signing out, asks how to start, making no account; the next '
|
||||
'start of the app asks again', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
|
||||
|
||||
bloc.add(const SessionEventSignOutRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const SessionStateLoading(),
|
||||
const SessionStateSignedOut(),
|
||||
]),
|
||||
);
|
||||
// Signing out of an anonymous account deletes it.
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signIns, 0);
|
||||
expect(mark.signedOut, isTrue);
|
||||
|
||||
await bloc.close();
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark)
|
||||
..add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('starting without an account from there signs in anonymously, and '
|
||||
'the device is no longer signed out', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
bloc.add(const SessionEventStartedAnonymously());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
|
||||
);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
|
||||
test('signing in with GitHub from there switches to its person; '
|
||||
'cancelled, it asks again', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
bloc.add(const SessionEventGitHubRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const SessionStateActive(
|
||||
SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(gateway.signIns, 0);
|
||||
expect(mark.signedOut, isFalse);
|
||||
});
|
||||
|
||||
test('a GitHub account nobody here signs in with yet is said so; any '
|
||||
'other refusal fails, and nothing is made either way', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
gateway.gitHubError = const GitHubSignInFailed(
|
||||
GitHubSignInProblem.unknownAccount,
|
||||
);
|
||||
bloc.add(const SessionEventGitHubRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const SessionStateSignedOut(busy: true),
|
||||
const SessionStateSignedOut(problem: SignedOutProblem.noGitHubAccount),
|
||||
]),
|
||||
);
|
||||
|
||||
gateway.gitHubError = const GitHubSignInFailed(GitHubSignInProblem.refused);
|
||||
bloc.add(const SessionEventGitHubRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const SessionStateSignedOut(busy: true),
|
||||
const SessionStateSignedOut(problem: SignedOutProblem.failed),
|
||||
]),
|
||||
);
|
||||
expect(gateway.signIns, 0);
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
});
|
||||
|
||||
test('with an email address from there, it starts without an account and '
|
||||
'asks for the address', () async {
|
||||
start(signedOut: true);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
|
||||
bloc.add(const SessionEventEmailRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const SessionStateActive(
|
||||
SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
signsInWithEmail: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a session the server ended asks how to start too', () async {
|
||||
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
|
||||
|
||||
gateway.revokeSession();
|
||||
|
||||
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
test('deleting the account asks how to start', () async {
|
||||
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
|
||||
start(status: stored);
|
||||
bloc.add(const SessionEventStarted());
|
||||
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
|
||||
|
||||
bloc.add(const SessionEventDeleteRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const SessionStateLoading(),
|
||||
const SessionStateSignedOut(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
}
|
||||
153
packages/halkyon_app/test/auth/data/auth_repository_test.dart
Normal file
153
packages/halkyon_app/test/auth/data/auth_repository_test.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthRepository.ensureSignedIn', () {
|
||||
test('signs in anonymously when nobody is signed in', () async {
|
||||
final gateway = FakeAuthGateway();
|
||||
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
|
||||
|
||||
expect(gateway.signIns, 1);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the person already signed in', () async {
|
||||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
final gateway = FakeAuthGateway(status: stored);
|
||||
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
|
||||
|
||||
expect(gateway.signIns, 0);
|
||||
expect(gateway.status, stored);
|
||||
});
|
||||
});
|
||||
|
||||
group('AuthRepository.signOut', () {
|
||||
test(
|
||||
'deletes an anonymous account, which nobody could sign into again',
|
||||
() async {
|
||||
final gateway = FakeAuthGateway(
|
||||
status: const SignedIn(
|
||||
authUserId: 'anonymous-1',
|
||||
isIdentified: false,
|
||||
),
|
||||
);
|
||||
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
|
||||
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signOuts, 0);
|
||||
expect(gateway.status, const SignedOut());
|
||||
},
|
||||
);
|
||||
|
||||
test('only signs an identified person out on this device', () async {
|
||||
final gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
|
||||
);
|
||||
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
|
||||
|
||||
expect(gateway.signOuts, 1);
|
||||
expect(gateway.accountDeletions, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('deletes the account of whoever is signed in', () async {
|
||||
final gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
|
||||
);
|
||||
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).deleteAccount();
|
||||
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.status, const SignedOut());
|
||||
});
|
||||
|
||||
group('signing in with an email address', () {
|
||||
late FakeAuthGateway gateway;
|
||||
late MemorySignInAddresses addresses;
|
||||
late AuthRepository auth;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
);
|
||||
addresses = MemorySignInAddresses();
|
||||
auth = AuthRepository(gateway, addresses);
|
||||
});
|
||||
|
||||
test("a code for a new address makes it the person's, and this device "
|
||||
'remembers it for them, to show', () async {
|
||||
final request = await auth.sendEmailCode(
|
||||
'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
final outcome = await auth.verifyEmailCode(
|
||||
request,
|
||||
'CODE-1',
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
|
||||
expect(outcome, const EmailLinked());
|
||||
expect(auth.isIdentified, isTrue);
|
||||
expect(await auth.emailAddress(), 'anna@example.org');
|
||||
});
|
||||
|
||||
test("a code for another person's address changes nothing here; "
|
||||
'switching remembers the address for the person switched to, not '
|
||||
'the one left', () async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
final request = await auth.sendEmailCode(
|
||||
'ben@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
final outcome = await auth.verifyEmailCode(
|
||||
request,
|
||||
'CODE-1',
|
||||
address: 'ben@example.org',
|
||||
);
|
||||
expect(outcome, isA<EmailTakenByAnotherPerson>());
|
||||
expect(auth.isIdentified, isFalse);
|
||||
expect(addresses.addresses, isEmpty);
|
||||
|
||||
await auth.switchToEmail(
|
||||
(outcome as EmailTakenByAnotherPerson).switchToken,
|
||||
address: 'ben@example.org',
|
||||
);
|
||||
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'ben', isIdentified: true),
|
||||
);
|
||||
expect(addresses.addresses, {'ben': 'ben@example.org'});
|
||||
expect(await auth.emailAddress(), 'ben@example.org');
|
||||
});
|
||||
|
||||
test('a wrong code remembers nothing', () async {
|
||||
final request = await auth.sendEmailCode(
|
||||
'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
auth.verifyEmailCode(request, 'CODE-9', address: 'anna@example.org'),
|
||||
throwsA(
|
||||
isA<EmailSignInFailed>().having(
|
||||
(failure) => failure.problem,
|
||||
'problem',
|
||||
EmailSignInProblem.wrongCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(addresses.addresses, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
259
packages/halkyon_app/test/auth/data/loopback_web_auth_test.dart
Normal file
259
packages/halkyon_app/test/auth/data/loopback_web_auth_test.dart
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show AppLifecycleState;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
|
||||
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
void main() {
|
||||
const gitHubPage = 'https://github.com/login/oauth/authorize?client_id=app';
|
||||
|
||||
late int port;
|
||||
late String callback;
|
||||
late Completer<Uri> pageOpened;
|
||||
late int broughtToFront;
|
||||
late LoopbackWebAuth webAuth;
|
||||
|
||||
/// The loopback part with a fake browser that only records the page it
|
||||
/// opens; [opens] is whether opening it works.
|
||||
LoopbackWebAuth loopback({
|
||||
bool opens = true,
|
||||
Future<void> Function()? bringToFront,
|
||||
Duration timeout = const Duration(minutes: 1),
|
||||
}) => LoopbackWebAuth(
|
||||
appName: 'Halkyon Learn',
|
||||
launch: (url) {
|
||||
pageOpened.complete(url);
|
||||
return Future.value(opens);
|
||||
},
|
||||
bringToFront:
|
||||
bringToFront ??
|
||||
() {
|
||||
broughtToFront++;
|
||||
return Future.value();
|
||||
},
|
||||
timeout: timeout,
|
||||
);
|
||||
|
||||
setUp(() async {
|
||||
// A port that is free right now, so the tests never meet a running app.
|
||||
final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
port = probe.port;
|
||||
await probe.close();
|
||||
callback = 'http://127.0.0.1:$port/github-callback';
|
||||
pageOpened = Completer();
|
||||
broughtToFront = 0;
|
||||
webAuth = loopback();
|
||||
});
|
||||
tearDown(() => webAuth.cancel());
|
||||
|
||||
/// Starts a sign-in as flutter_web_auth_2 hands it over.
|
||||
Future<String> signIn() => webAuth.authenticate(
|
||||
url: gitHubPage,
|
||||
callbackUrlScheme: callback,
|
||||
options: const {},
|
||||
);
|
||||
|
||||
/// The browser asking this machine's [address] for [pathAndQuery], as when
|
||||
/// GitHub sends it back. Over a raw socket, so that the fake HTTP of
|
||||
/// Flutter's test binding can't get in between.
|
||||
Future<({int status, String body})> browserAsks(
|
||||
String pathAndQuery, {
|
||||
InternetAddress? address,
|
||||
}) async {
|
||||
final socket = await Socket.connect(
|
||||
address ?? InternetAddress.loopbackIPv4,
|
||||
port,
|
||||
timeout: const Duration(seconds: 2),
|
||||
);
|
||||
socket.write(
|
||||
'GET $pathAndQuery HTTP/1.1\r\n'
|
||||
'Host: 127.0.0.1:$port\r\n'
|
||||
'Connection: close\r\n\r\n',
|
||||
);
|
||||
final response = await utf8.decoder.bind(socket).join();
|
||||
socket.destroy();
|
||||
return (
|
||||
status: int.parse(response.split(' ')[1]),
|
||||
body: response.substring(response.indexOf('\r\n\r\n') + 4),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"opens GitHub's page and returns where the browser comes back to",
|
||||
() async {
|
||||
final result = signIn();
|
||||
|
||||
expect(await pageOpened.future, Uri.parse(gitHubPage));
|
||||
final response = await browserAsks('/github-callback?code=c&state=s');
|
||||
|
||||
expect(await result, '$callback?code=c&state=s');
|
||||
expect(response.status, HttpStatus.ok);
|
||||
expect(response.body, contains('return to Halkyon Learn'));
|
||||
expect(broughtToFront, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"keeps waiting when the app's window regains focus",
|
||||
() async {
|
||||
// The way the bug went: flutter_web_auth_2's front ends every pending
|
||||
// sign-in when the app is resumed, and on the desktop the window
|
||||
// regaining focus resumes it.
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
FlutterWebAuth2Platform.instance = webAuth;
|
||||
final result = FlutterWebAuth2.authenticate(
|
||||
url: gitHubPage,
|
||||
callbackUrlScheme: callback,
|
||||
);
|
||||
await pageOpened.future;
|
||||
|
||||
// The browser takes the focus; then the person looks back at the app.
|
||||
await _embedderReports(AppLifecycleState.inactive);
|
||||
await _embedderReports(AppLifecycleState.resumed);
|
||||
final response = await browserAsks('/github-callback?code=c&state=s');
|
||||
|
||||
expect(response.status, HttpStatus.ok);
|
||||
expect(await result, '$callback?code=c&state=s');
|
||||
},
|
||||
// Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback.
|
||||
skip: !(Platform.isLinux || Platform.isWindows),
|
||||
);
|
||||
|
||||
test('a cancel ends the wait as cancelled and stops listening', () async {
|
||||
final result = signIn();
|
||||
await pageOpened.future;
|
||||
|
||||
await webAuth.cancel();
|
||||
|
||||
await expectLater(result, throwsA(_cancelled));
|
||||
await expectLater(
|
||||
browserAsks('/github-callback?code=c&state=s'),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('gives up after the timeout and stops listening', () async {
|
||||
webAuth = loopback(timeout: const Duration(milliseconds: 50));
|
||||
|
||||
await expectLater(signIn(), throwsA(isA<TimeoutException>()));
|
||||
await expectLater(
|
||||
browserAsks('/github-callback?code=c&state=s'),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a second sign-in ends the first as cancelled', () async {
|
||||
final first = signIn();
|
||||
await pageOpened.future;
|
||||
pageOpened = Completer();
|
||||
|
||||
final second = signIn();
|
||||
|
||||
await expectLater(first, throwsA(_cancelled));
|
||||
await pageOpened.future;
|
||||
await browserAsks('/github-callback?code=c&state=s');
|
||||
expect(await second, '$callback?code=c&state=s');
|
||||
});
|
||||
|
||||
test('answers other paths with "not found" and keeps waiting', () async {
|
||||
final result = signIn();
|
||||
await pageOpened.future;
|
||||
|
||||
final stray = await browserAsks('/favicon.ico');
|
||||
final response = await browserAsks('/github-callback?code=c&state=s');
|
||||
|
||||
expect(stray.status, HttpStatus.notFound);
|
||||
expect(response.status, HttpStatus.ok);
|
||||
expect(await result, '$callback?code=c&state=s');
|
||||
});
|
||||
|
||||
test('listens on the loopback address only', () async {
|
||||
final otherAddresses = [
|
||||
for (final interface in await NetworkInterface.list())
|
||||
for (final address in interface.addresses)
|
||||
if (!address.isLoopback) address,
|
||||
];
|
||||
final result = signIn();
|
||||
await pageOpened.future;
|
||||
|
||||
for (final address in otherAddresses) {
|
||||
await expectLater(
|
||||
browserAsks('/github-callback?code=c&state=s', address: address),
|
||||
throwsA(isA<SocketException>()),
|
||||
reason: '$address',
|
||||
);
|
||||
}
|
||||
await browserAsks('/github-callback?code=c&state=s');
|
||||
expect(await result, '$callback?code=c&state=s');
|
||||
});
|
||||
|
||||
test("stops listening when the browser can't be opened", () async {
|
||||
webAuth = loopback(opens: false);
|
||||
|
||||
await expectLater(signIn(), throwsA(isA<PlatformException>()));
|
||||
await expectLater(
|
||||
browserAsks('/github-callback?code=c&state=s'),
|
||||
throwsA(isA<SocketException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test("signs in even when the window can't come to the front", () async {
|
||||
webAuth = loopback(
|
||||
bringToFront: () => Future.error(MissingPluginException()),
|
||||
);
|
||||
final result = signIn();
|
||||
await pageOpened.future;
|
||||
|
||||
await browserAsks('/github-callback?code=c&state=s');
|
||||
|
||||
expect(await result, '$callback?code=c&state=s');
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses a callback other than a path on 127.0.0.1 with a port',
|
||||
() async {
|
||||
for (final address in [
|
||||
'http://0.0.0.0:$port/github-callback',
|
||||
'http://localhost:$port/github-callback',
|
||||
'http://example.com:$port/github-callback',
|
||||
'https://127.0.0.1:$port/github-callback',
|
||||
'http://127.0.0.1/github-callback',
|
||||
'http://127.0.0.1:$port',
|
||||
]) {
|
||||
await expectLater(
|
||||
webAuth.authenticate(
|
||||
url: gitHubPage,
|
||||
callbackUrlScheme: address,
|
||||
options: const {},
|
||||
),
|
||||
throwsArgumentError,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
expect(pageOpened.isCompleted, isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// How flutter_web_auth_2 reports a cancelled sign-in, which is how the
|
||||
/// gateway's callers learn of it.
|
||||
final _cancelled = isA<PlatformException>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
'CANCELED',
|
||||
);
|
||||
|
||||
/// The embedder reporting the app's lifecycle, as it does when the window
|
||||
/// gains or loses focus.
|
||||
Future<void> _embedderReports(AppLifecycleState state) =>
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
SystemChannels.lifecycle.name,
|
||||
SystemChannels.lifecycle.codec.encodeMessage(state.toString()),
|
||||
(_) {},
|
||||
);
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
|
||||
/// A session kept in memory, as no test reaches the system's storage.
|
||||
final class _MemoryStorage() implements ClientAuthSuccessStorage {
|
||||
AuthSuccess? _session;
|
||||
|
||||
@override
|
||||
Future<AuthSuccess?> get() async => _session;
|
||||
|
||||
@override
|
||||
Future<void> set(AuthSuccess? data) async => _session = data;
|
||||
}
|
||||
|
||||
/// An instance's halkyon_base as the network shows it: anonymous sign-ins
|
||||
/// get `anonymous-token`, its basics offer GitHub and email, and an address
|
||||
/// belongs to Ben, whom a switch signs in as with `ben-token`.
|
||||
final class _Instance() {
|
||||
static final anonymous = UuidValue.withValidation(
|
||||
'0191f0c4-1111-7000-8000-000000000001',
|
||||
);
|
||||
static final ben = UuidValue.withValidation(
|
||||
'0191f0c4-2222-7000-8000-000000000002',
|
||||
);
|
||||
|
||||
/// Each call, by its endpoint and method, with the session it carried.
|
||||
final calls = <({String call, String? session})>[];
|
||||
|
||||
/// What mailing a code is refused for, while set.
|
||||
EmailSignInRefusal? sendRefused;
|
||||
|
||||
http.Client client() => MockClient((request) async {
|
||||
final call = request.url.path.substring(1);
|
||||
calls.add((call: call, session: request.headers['authorization']));
|
||||
String sent(Object? body) => jsonEncode(body);
|
||||
return switch (call) {
|
||||
'halkyon_base.anonymousIdp/login' => http.Response(
|
||||
sent(_session(anonymous, 'anonymous-token', identified: false)),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.instance/basics' => http.Response(
|
||||
sent(
|
||||
InstanceBasics(
|
||||
app: 'learn',
|
||||
gitHubClientId: 'github-client',
|
||||
emailSignIn: true,
|
||||
sourceCodeUrl: 'https://code.example.org/learn',
|
||||
minimumAppBuild: 1,
|
||||
).toJson(),
|
||||
),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.emailCode/sendCode' => switch (sendRefused) {
|
||||
final reason? => http.Response(
|
||||
sent({
|
||||
'className': 'halkyon_base.EmailSignInRefusedException',
|
||||
'data': EmailSignInRefusedException(reason: reason).toJson(),
|
||||
}),
|
||||
400,
|
||||
),
|
||||
null => http.Response(
|
||||
sent('0191f0c4-3333-7000-8000-000000000003'),
|
||||
200,
|
||||
),
|
||||
},
|
||||
'halkyon_base.emailCode/verifyCode' => http.Response(
|
||||
sent(EmailCodeAccepted(switchToken: 'switch-to-ben').toJson()),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.emailSignIn/signIn' => http.Response(
|
||||
sent(_session(ben, 'ben-token', identified: true)),
|
||||
200,
|
||||
),
|
||||
'halkyon_base.account/delete' => http.Response('null', 200),
|
||||
_ => http.Response('', 404),
|
||||
};
|
||||
});
|
||||
|
||||
static Map<String, dynamic> _session(
|
||||
UuidValue person,
|
||||
String token, {
|
||||
required bool identified,
|
||||
}) => AuthSuccess(
|
||||
authStrategy: 'session',
|
||||
token: token,
|
||||
authUserId: person,
|
||||
scopeNames: {if (identified) 'identified'},
|
||||
).toJson();
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _Instance instance;
|
||||
late BaseClient app;
|
||||
late ServerpodAuthGateway gateway;
|
||||
|
||||
setUp(() async {
|
||||
instance = _Instance();
|
||||
app =
|
||||
BaseClient(
|
||||
'https://learn.example.org/',
|
||||
httpClientOverride: instance.client(),
|
||||
)
|
||||
..authSessionManager = FlutterAuthSessionManager(
|
||||
storage: _MemoryStorage(),
|
||||
);
|
||||
await app.auth.initialize();
|
||||
gateway = ServerpodAuthGateway(
|
||||
app,
|
||||
base: app.modules,
|
||||
callbackScheme: 'app.halkyon.test',
|
||||
appName: 'Halkyon Test',
|
||||
httpClient: instance.client,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await gateway.dispose();
|
||||
app.close();
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'signs in anonymously through halkyon_base, nobody identified',
|
||||
() async {
|
||||
await gateway.signInAnonymously();
|
||||
|
||||
expect(
|
||||
gateway.status,
|
||||
SignedIn(authUserId: '${_Instance.anonymous}', isIdentified: false),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('tells what the instance offers from its basics', () async {
|
||||
expect(await gateway.gitHubClientId(), 'github-client');
|
||||
expect(await gateway.offersEmailSignIn(), isTrue);
|
||||
});
|
||||
|
||||
test("the instance's refusals to mail a code arrive in the package's own "
|
||||
'words', () async {
|
||||
await gateway.signInAnonymously();
|
||||
instance.sendRefused = EmailSignInRefusal.tooManyCodes;
|
||||
|
||||
await expectLater(
|
||||
gateway.sendEmailCode('ben@example.org', language: 'en'),
|
||||
throwsA(
|
||||
isA<EmailSignInFailed>().having(
|
||||
(failure) => failure.problem,
|
||||
'problem',
|
||||
EmailSignInProblem.tooManyCodes,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("switching to an address's person signs in without this device's "
|
||||
'session, then deletes the anonymous account left behind as '
|
||||
'itself', () async {
|
||||
await gateway.signInAnonymously();
|
||||
final request = await gateway.sendEmailCode(
|
||||
'ben@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
final outcome = await gateway.verifyEmailCode(request, 'K7QXM2PD');
|
||||
expect(outcome, const EmailTakenByAnotherPerson('switch-to-ben'));
|
||||
|
||||
expect(await gateway.switchToEmail('switch-to-ben'), '${_Instance.ben}');
|
||||
|
||||
expect(
|
||||
gateway.status,
|
||||
SignedIn(authUserId: '${_Instance.ben}', isIdentified: true),
|
||||
);
|
||||
final signIn = instance.calls.singleWhere(
|
||||
(call) => call.call == 'halkyon_base.emailSignIn/signIn',
|
||||
);
|
||||
expect(signIn.session, isNull, reason: 'no session goes with it');
|
||||
final deletion = instance.calls.singleWhere(
|
||||
(call) => call.call == 'halkyon_base.account/delete',
|
||||
);
|
||||
expect(deletion.session, contains('anonymous-token'));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
/// Whether this device holds content a switch would leave behind.
|
||||
late bool holdsContent;
|
||||
|
||||
/// What the sheet returned once it closed.
|
||||
late List<String?> returned;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
);
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
holdsContent = false;
|
||||
returned = [];
|
||||
});
|
||||
|
||||
/// Opens the sheet, in [locale].
|
||||
Future<void> open(
|
||||
WidgetTester tester, {
|
||||
Locale locale = const Locale('en'),
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(412, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () async => returned.add(
|
||||
await showEmailSignInSheet(
|
||||
context,
|
||||
deviceHoldsContent: () async => holdsContent,
|
||||
),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
dependencies: dependencies,
|
||||
locale: locale,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The sheet's text field.
|
||||
final inSheet = find.descendant(
|
||||
of: find.byType(SheetScaffold),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
|
||||
/// Types [address] and sends a code to it, then types [code]. A switch to
|
||||
/// another person shows a spinner until their app replaces the whole
|
||||
/// subtree, which a test never settles: [switches] pumps frames instead.
|
||||
Future<void> signInWith(
|
||||
WidgetTester tester,
|
||||
String address, {
|
||||
String code = 'CODE-1',
|
||||
bool switches = false,
|
||||
}) async {
|
||||
await tester.enterText(inSheet, address);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Send code'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text("We mailed a code to $address. It's valid for 15 minutes."),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(inSheet, code);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Continue'));
|
||||
if (switches) {
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
} else {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('an anonymous person continues with email: the address, then '
|
||||
'the code mailed to it, after which the address is theirs', (
|
||||
tester,
|
||||
) async {
|
||||
await open(tester);
|
||||
expect(
|
||||
find.textContaining('keeps only a fingerprint of your address'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await signInWith(tester, 'anna@example.org');
|
||||
|
||||
expect(returned, ['anna@example.org']);
|
||||
expect(gateway.mailed.single.language, 'en');
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
expect(dependencies.addresses.addresses, {
|
||||
'anonymous-1': 'anna@example.org',
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the mail is worded in German for someone who reads the app in '
|
||||
'German', (tester) async {
|
||||
await open(tester, locale: const Locale('de'));
|
||||
await tester.enterText(inSheet, 'anna@example.org');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Code senden'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.mailed.single.language, 'de');
|
||||
});
|
||||
|
||||
testWidgets('a wrong code says so, and a new one can be asked for', (
|
||||
tester,
|
||||
) async {
|
||||
await open(tester);
|
||||
|
||||
await signInWith(tester, 'anna@example.org', code: 'CODE-9');
|
||||
|
||||
expect(find.text("That code isn't right."), findsOneWidget);
|
||||
await tester.tap(find.text('Send a new code'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('A new code is on its way.'), findsOneWidget);
|
||||
expect(gateway.mailed, hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets("another person's address switches at once where the device "
|
||||
'holds nothing', (tester) async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
await open(tester);
|
||||
|
||||
await signInWith(tester, 'ben@example.org', switches: true);
|
||||
|
||||
expect(gateway.emailSwitches, 1);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'ben', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("another person's address asks first where the device holds "
|
||||
"content, in the app's own words: staying changes nothing, switching "
|
||||
'switches', (tester) async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
holdsContent = true;
|
||||
await open(tester);
|
||||
|
||||
await signInWith(tester, 'ben@example.org');
|
||||
|
||||
expect(
|
||||
find.text('Switch to the account of ben@example.org?'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(
|
||||
"The notes on this device can't be combined with it yet: if you "
|
||||
'switch, they are deleted.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Stay anonymous'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(gateway.emailSwitches, 0);
|
||||
|
||||
await signInWith(tester, 'ben@example.org', code: 'CODE-2');
|
||||
await tester.tap(find.text('Switch'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(gateway.emailSwitches, 1);
|
||||
});
|
||||
|
||||
testWidgets("someone identified whose address is another account's is "
|
||||
'told so, and stays who they are', (tester) async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
)..addressOwners['ben@example.org'] = 'ben';
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
await open(tester);
|
||||
|
||||
await signInWith(tester, 'ben@example.org');
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'ben@example.org already signs in to another account. To switch to '
|
||||
'it, sign out first.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(gateway.emailSwitches, 0);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
);
|
||||
});
|
||||
}
|
||||
173
packages/halkyon_app/test/auth/widget/session_gate_test.dart
Normal file
173
packages/halkyon_app/test/auth/widget/session_gate_test.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A signed-in person's app as a test makes one: whose it is, whether
|
||||
/// they asked to continue with email, and a way to sign out.
|
||||
class const _PersonApp(final SignedIn person) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
localizationsDelegates: testLocalizationsDelegates,
|
||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Column(
|
||||
children: [
|
||||
Text('App of ${person.authUserId}'),
|
||||
if (context.watch<SessionBloc>().state case SessionStateActive(
|
||||
signsInWithEmail: true,
|
||||
))
|
||||
const Text('Asks for the address'),
|
||||
TextButton(
|
||||
onPressed: () => context.read<SessionBloc>().add(
|
||||
const SessionEventSignOutRequested(),
|
||||
),
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway();
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
});
|
||||
|
||||
Future<void> start(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
AppFrameScope(
|
||||
frame: testFrame(),
|
||||
child: AppScope(
|
||||
dependencies: dependencies,
|
||||
child: SessionGate(
|
||||
look: (context) => (themeMode: ThemeMode.light, locale: null),
|
||||
app: _PersonApp.new,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('shows the app once signed in anonymously at a first start', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester);
|
||||
|
||||
expect(find.text('App of anonymous-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('offers to try again when signing in fails', (tester) async {
|
||||
gateway.signInError = Exception('No connection');
|
||||
await start(tester);
|
||||
|
||||
expect(find.text("Couldn't reach the server."), findsOneWidget);
|
||||
|
||||
gateway.signInError = null;
|
||||
await tester.tap(find.text('Try again'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('App of anonymous-2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('signed out, it asks how to start on the instance, making no '
|
||||
'account; starting without one makes one', (tester) async {
|
||||
await dependencies.signedOut.write(signedOut: true);
|
||||
await start(tester);
|
||||
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
expect(find.text('Start on test.example.org:'), findsOneWidget);
|
||||
expect(find.text('Sign in with GitHub'), findsOneWidget);
|
||||
expect(find.text('Continue with email'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Use another instance'),
|
||||
findsNothing,
|
||||
reason: 'no instances to choose among here',
|
||||
);
|
||||
expect(gateway.signIns, 0);
|
||||
|
||||
await tester.tap(find.text('Start without an account'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('App of anonymous-1'), findsOneWidget);
|
||||
expect(dependencies.signedOut.signedOut, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('from there, GitHub signs in its person; a GitHub account '
|
||||
'nobody here has yet is said so', (tester) async {
|
||||
await dependencies.signedOut.write(signedOut: true);
|
||||
await start(tester);
|
||||
|
||||
gateway.gitHubError = const GitHubSignInFailed(
|
||||
GitHubSignInProblem.unknownAccount,
|
||||
);
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'No account here signs in with this GitHub account yet. Start '
|
||||
'without an account, then add GitHub in Profile.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
gateway.gitHubError = null;
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('App of github-person'), findsOneWidget);
|
||||
expect(gateway.signIns, 0);
|
||||
});
|
||||
|
||||
testWidgets('from there, an email address starts without an account and '
|
||||
'asks for the address in the app', (tester) async {
|
||||
await dependencies.signedOut.write(signedOut: true);
|
||||
await start(tester);
|
||||
|
||||
await tester.tap(find.text('Continue with email'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('App of anonymous-1'), findsOneWidget);
|
||||
expect(find.text('Asks for the address'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('offers only the ways the instance has', (tester) async {
|
||||
gateway
|
||||
..offersGitHub = false
|
||||
..offersEmail = false;
|
||||
await dependencies.signedOut.write(signedOut: true);
|
||||
await start(tester);
|
||||
|
||||
expect(find.text('Sign in with GitHub'), findsNothing);
|
||||
expect(find.text('Continue with email'), findsNothing);
|
||||
expect(find.text('Start without an account'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('signing out, or the instance ending the session, asks how to '
|
||||
'start', (tester) async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'ada', isIdentified: true),
|
||||
);
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
await start(tester);
|
||||
expect(find.text('App of ada'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
expect(dependencies.signedOut.signedOut, isTrue);
|
||||
|
||||
await tester.tap(find.text('Start without an account'));
|
||||
await tester.pumpAndSettle();
|
||||
gateway.revokeSession();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Signed out'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
106
packages/halkyon_app/test/base/base_client_test.dart
Normal file
106
packages/halkyon_app/test/base/base_client_test.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// [refusal] as an instance sends it: named by its module.
|
||||
String sent(SerializableException refusal) => jsonEncode({
|
||||
'className':
|
||||
'halkyon_base.${switch (refusal) {
|
||||
EmailSignInRefusedException() => 'EmailSignInRefusedException',
|
||||
GitHubSignInRefusedException() => 'GitHubSignInRefusedException',
|
||||
_ => throw ArgumentError.value(refusal),
|
||||
}}',
|
||||
'data': (refusal as SerializableModel).toJson(),
|
||||
});
|
||||
|
||||
void main() {
|
||||
test("reads the refusals an instance sends under halkyon_base's name, as "
|
||||
"an app's own generated protocol does", () {
|
||||
final protocol = BaseProtocol();
|
||||
|
||||
expect(
|
||||
protocol.decodeWithType(
|
||||
sent(
|
||||
EmailSignInRefusedException(reason: EmailSignInRefusal.wrongCode),
|
||||
),
|
||||
),
|
||||
isA<EmailSignInRefusedException>().having(
|
||||
(refusal) => refusal.reason,
|
||||
'reason',
|
||||
EmailSignInRefusal.wrongCode,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
protocol.decodeWithType(
|
||||
sent(
|
||||
GitHubSignInRefusedException(
|
||||
reason: GitHubSignInRefusal.unknownAccount,
|
||||
),
|
||||
),
|
||||
),
|
||||
isA<GitHubSignInRefusedException>().having(
|
||||
(refusal) => refusal.reason,
|
||||
'reason',
|
||||
GitHubSignInRefusal.unknownAccount,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("calls halkyon_base's endpoints at the instance, and passes its "
|
||||
'refusals on as themselves', () async {
|
||||
final asked = <String>[];
|
||||
final client = BaseClient(
|
||||
'https://learn.example.org/',
|
||||
httpClientOverride: MockClient((request) async {
|
||||
asked.add(request.url.toString());
|
||||
return switch (request.url.path) {
|
||||
'/halkyon_base.instance/basics' => http.Response(
|
||||
jsonEncode(
|
||||
InstanceBasics(
|
||||
app: 'learn',
|
||||
emailSignIn: true,
|
||||
sourceCodeUrl: 'https://code.example.org/learn',
|
||||
minimumAppBuild: 21,
|
||||
dataLocation: 'EU',
|
||||
).toJson(),
|
||||
),
|
||||
200,
|
||||
),
|
||||
'/halkyon_base.emailSignIn/signIn' => http.Response(
|
||||
sent(
|
||||
EmailSignInRefusedException(
|
||||
reason: EmailSignInRefusal.codeExpired,
|
||||
),
|
||||
),
|
||||
400,
|
||||
),
|
||||
_ => http.Response('', 404),
|
||||
};
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final basics = await client.modules.instance.basics();
|
||||
expect(basics.emailSignIn, isTrue);
|
||||
expect(basics.minimumAppBuild, 21);
|
||||
expect(basics.dataLocation, 'EU');
|
||||
await expectLater(
|
||||
client.modules.emailSignIn.signIn(switchToken: 'token'),
|
||||
throwsA(
|
||||
isA<EmailSignInRefusedException>().having(
|
||||
(refusal) => refusal.reason,
|
||||
'reason',
|
||||
EmailSignInRefusal.codeExpired,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(asked, [
|
||||
'https://learn.example.org/halkyon_base.instance/basics',
|
||||
'https://learn.example.org/halkyon_base.emailSignIn/signIn',
|
||||
]);
|
||||
});
|
||||
}
|
||||
16
packages/halkyon_app/test/instance/country_names_test.dart
Normal file
16
packages/halkyon_app/test/instance/country_names_test.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
void main() {
|
||||
test('a country is named in the language asked for, else in English, and '
|
||||
'one without a name not at all', () async {
|
||||
final names = MemoryCountryNames({
|
||||
'CH': {'de': 'Schweiz', 'en': 'Switzerland'},
|
||||
});
|
||||
|
||||
expect(await names.nameOf('CH', 'de'), 'Schweiz');
|
||||
expect(await names.nameOf('ch', 'de'), 'Schweiz');
|
||||
expect(await names.nameOf('CH', 'ja'), 'Switzerland');
|
||||
expect(await names.nameOf('XX', 'en'), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test("the flags' MIT notice joins the licence list, as their licence "
|
||||
'asks', () async {
|
||||
registerFlagIconsLicense();
|
||||
|
||||
final notices = [
|
||||
await for (final entry in LicenseRegistry.licenses)
|
||||
if (entry.packages.contains('flag-icons'))
|
||||
entry.paragraphs.map((paragraph) => paragraph.text).join('\n'),
|
||||
];
|
||||
expect(notices, hasLength(1));
|
||||
expect(notices.single, contains('Copyright (c) 2013 Panayiotis Lipiridis'));
|
||||
expect(
|
||||
notices.single,
|
||||
contains(
|
||||
'The above copyright notice and this permission notice shall be '
|
||||
'included',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
final halkyon = Instance.parse('learn.example.org')!;
|
||||
final todo = Instance.parse('todo.example.org')!;
|
||||
final elsewhere = Instance.parse('www.example.org')!;
|
||||
|
||||
/// Basics as an instance of [app] tells them.
|
||||
http.Response basicsOf(String app) => http.Response(
|
||||
jsonEncode(
|
||||
InstanceBasics(
|
||||
app: app,
|
||||
emailSignIn: false,
|
||||
sourceCodeUrl: 'https://code.example.org/$app',
|
||||
minimumAppBuild: 1,
|
||||
dataLocation: 'EU',
|
||||
backupLocation: 'CH',
|
||||
).toJson(),
|
||||
),
|
||||
200,
|
||||
);
|
||||
|
||||
/// A checker of the app `learn` reaching instances through a fake network,
|
||||
/// where learn.example.org answers as an instance of it, todo.example.org
|
||||
/// as an instance of another app, and nothing else answers.
|
||||
ServerpodInstanceChecker checker() => ServerpodInstanceChecker(
|
||||
app: 'learn',
|
||||
httpClient: () => MockClient(
|
||||
(request) async => switch ((request.url.host, request.url.path)) {
|
||||
('learn.example.org', '/halkyon_base.instance/basics') => basicsOf(
|
||||
'learn',
|
||||
),
|
||||
('todo.example.org', '/halkyon_base.instance/basics') => basicsOf(
|
||||
'todo',
|
||||
),
|
||||
_ => http.Response('<html>Not found</html>', 404),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
test('an instance answers when its halkyon_base tells its basics; any '
|
||||
'other site does not', () async {
|
||||
expect(await checker().answers(halkyon), isTrue);
|
||||
expect(await checker().answers(elsewhere), isFalse);
|
||||
});
|
||||
|
||||
test("another app's instance does not answer, so its address is refused "
|
||||
'before the app moves there', () async {
|
||||
expect(await checker().answers(todo), isFalse);
|
||||
expect(await checker().locationsOf(todo), isNull);
|
||||
});
|
||||
|
||||
test("says where an instance keeps people's data and its backups, and "
|
||||
'nothing for a site that is no instance', () async {
|
||||
expect(
|
||||
await checker().locationsOf(halkyon),
|
||||
const InstanceLocations(data: 'EU', backups: 'CH'),
|
||||
);
|
||||
expect(await checker().locationsOf(elsewhere), isNull);
|
||||
});
|
||||
}
|
||||
86
packages/halkyon_app/test/instance/instance_choice_test.dart
Normal file
86
packages/halkyon_app/test/instance/instance_choice_test.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
void main() {
|
||||
final builtIn = Instance.parse('learn.halkyon.eu')!;
|
||||
final other = Instance.parse('learn.example.org')!;
|
||||
late MemoryInstanceStore store;
|
||||
late List<Instance> forgotten;
|
||||
|
||||
InstanceChoice choice() => InstanceChoice(
|
||||
store,
|
||||
builtIn: builtIn,
|
||||
forget: (instance) async {
|
||||
forgotten.add(instance);
|
||||
},
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
store = MemoryInstanceStore();
|
||||
forgotten = [];
|
||||
});
|
||||
|
||||
test('a new install has no instance until one is chosen', () async {
|
||||
final instances = choice();
|
||||
await instances.load(usedBefore: false);
|
||||
|
||||
expect(instances.current, isNull);
|
||||
expect(instances.known, isEmpty);
|
||||
|
||||
await instances.use(other);
|
||||
expect(instances.current, other);
|
||||
expect(store.kept?.current, other);
|
||||
expect(store.kept?.known, [other]);
|
||||
});
|
||||
|
||||
test('an install from before there was a choice goes on with the '
|
||||
"build's instance", () async {
|
||||
final instances = choice();
|
||||
await instances.load(usedBefore: true);
|
||||
|
||||
expect(instances.current, builtIn);
|
||||
expect(store.kept?.current, builtIn);
|
||||
expect(store.kept?.known, [builtIn]);
|
||||
});
|
||||
|
||||
test('what was chosen is read back', () async {
|
||||
store = MemoryInstanceStore((current: other, known: [builtIn, other]));
|
||||
final instances = choice();
|
||||
await instances.load(usedBefore: true);
|
||||
|
||||
expect(instances.current, other);
|
||||
expect(instances.known, [builtIn, other]);
|
||||
});
|
||||
|
||||
test('switching keeps every account; removing one forgets what the '
|
||||
'device holds of it, never of the one in use', () async {
|
||||
final instances = choice();
|
||||
await instances.load(usedBefore: true);
|
||||
await instances.use(other);
|
||||
await instances.use(builtIn);
|
||||
expect(instances.known, [builtIn, other]);
|
||||
|
||||
await instances.remove(builtIn);
|
||||
expect(forgotten, isEmpty, reason: 'in use');
|
||||
|
||||
await instances.remove(other);
|
||||
expect(forgotten, [other]);
|
||||
expect(instances.known, [builtIn]);
|
||||
expect(store.kept?.current, builtIn);
|
||||
expect(store.kept?.known, [builtIn]);
|
||||
});
|
||||
|
||||
test('tells its listeners of every change', () async {
|
||||
final instances = choice();
|
||||
var changes = 0;
|
||||
instances.addListener(() => changes++);
|
||||
|
||||
await instances.load(usedBefore: false);
|
||||
await instances.use(other);
|
||||
await instances.use(builtIn);
|
||||
await instances.remove(other);
|
||||
|
||||
expect(changes, 4);
|
||||
});
|
||||
}
|
||||
51
packages/halkyon_app/test/instance/instance_test.dart
Normal file
51
packages/halkyon_app/test/instance/instance_test.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
void main() {
|
||||
test('reads an address as people type it, HTTPS unless they say not', () {
|
||||
for (final address in [
|
||||
'learn.example.org',
|
||||
'https://learn.example.org',
|
||||
' HTTPS://Learn.Example.org/ ',
|
||||
]) {
|
||||
expect(
|
||||
Instance.parse(address)?.url,
|
||||
Uri.parse('https://learn.example.org/'),
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
Instance.parse('http://localhost:8080')?.url,
|
||||
Uri.parse('http://localhost:8080/'),
|
||||
);
|
||||
expect(
|
||||
Instance.parse('example.org/learn?x=1#top')?.url,
|
||||
Uri.parse('https://example.org/learn/'),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses what is no address', () {
|
||||
for (final address in [
|
||||
'',
|
||||
' ',
|
||||
'learn example org',
|
||||
'ftp://example.org',
|
||||
'https://',
|
||||
'https://someone@example.org',
|
||||
]) {
|
||||
expect(Instance.parse(address), isNull, reason: address);
|
||||
}
|
||||
});
|
||||
|
||||
test('is named by its host, and its port if it has one', () {
|
||||
expect(Instance.parse('learn.halkyon.eu')!.name, 'learn.halkyon.eu');
|
||||
expect(Instance.parse('localhost:8080')!.name, 'localhost:8080');
|
||||
});
|
||||
|
||||
test('is the same instance however its address was written', () {
|
||||
expect(
|
||||
Instance.parse('learn.example.org'),
|
||||
Instance.parse('https://LEARN.example.org/'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations_de.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations_en.dart';
|
||||
|
||||
void main() {
|
||||
final en = HalkyonAppLocalizationsEn();
|
||||
final de = HalkyonAppLocalizationsDe();
|
||||
const names = {'CH': 'Switzerland', 'DE': 'Germany'};
|
||||
String? words(InstanceLocations locations) =>
|
||||
instanceLocationWords(en, locations, names);
|
||||
|
||||
test('data and backups in one place are named once', () {
|
||||
expect(
|
||||
words(const InstanceLocations(data: 'EU', backups: 'EU')),
|
||||
'Data and backups: European Union',
|
||||
);
|
||||
});
|
||||
|
||||
test('data and backups apart are named a line each', () {
|
||||
expect(
|
||||
words(const InstanceLocations(data: 'EU', backups: 'CH')),
|
||||
'Data: European Union\nBackups: Switzerland',
|
||||
);
|
||||
});
|
||||
|
||||
test('an instance keeping no backups says so', () {
|
||||
expect(
|
||||
words(
|
||||
const InstanceLocations(
|
||||
data: 'CH',
|
||||
backups: InstanceLocations.noBackups,
|
||||
),
|
||||
),
|
||||
'Data: Switzerland\nNo backups',
|
||||
);
|
||||
});
|
||||
|
||||
test('what the instance leaves unsaid is left out', () {
|
||||
expect(words(const InstanceLocations(data: 'DE')), 'Data: Germany');
|
||||
expect(
|
||||
words(const InstanceLocations(backups: 'CH')),
|
||||
'Backups: Switzerland',
|
||||
);
|
||||
expect(words(const InstanceLocations()), isNull);
|
||||
});
|
||||
|
||||
test('a country without a name here goes by its code', () {
|
||||
expect(words(const InstanceLocations(data: 'XK')), 'Data: XK');
|
||||
});
|
||||
|
||||
test('in German too, without the articles some countries take', () {
|
||||
expect(
|
||||
instanceLocationWords(
|
||||
de,
|
||||
const InstanceLocations(data: 'EU', backups: 'CH'),
|
||||
const {'CH': 'Schweiz'},
|
||||
),
|
||||
'Daten: Europäische Union\nSicherungen: Schweiz',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
late List<String> tapped;
|
||||
|
||||
setUp(() => tapped = []);
|
||||
|
||||
/// Shows a tile beside something else to move the pointer or focus to.
|
||||
Future<void> show(WidgetTester tester, {bool? expanded}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
TextButton(onPressed: () {}, child: const Text('Elsewhere')),
|
||||
InstanceTile(
|
||||
icon: const Icon(LucideIcons.globe),
|
||||
label: 'learn.halkyon.eu',
|
||||
tagline: 'Comes with the app',
|
||||
expanded: expanded,
|
||||
onTap: () => tapped.add('tile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double ring(WidgetTester tester) =>
|
||||
tester.widget<Opacity>(find.byKey(InstanceTile.ringKey)).opacity;
|
||||
|
||||
double tagline(WidgetTester tester) => tester
|
||||
.widget<Opacity>(
|
||||
find.ancestor(
|
||||
of: find.text('Comes with the app'),
|
||||
matching: find.byType(Opacity),
|
||||
),
|
||||
)
|
||||
.opacity;
|
||||
|
||||
testWidgets('without a mouse, the tagline shows from the start', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
expect(tagline(tester), 1);
|
||||
expect(ring(tester), 0);
|
||||
});
|
||||
|
||||
testWidgets('with a mouse, the ring and the tagline come in under the '
|
||||
'pointer and go once it leaves', (tester) async {
|
||||
await show(tester);
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(location: tester.getCenter(find.text('Elsewhere')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tagline(tester), 0);
|
||||
expect(ring(tester), 0);
|
||||
|
||||
await mouse.moveTo(tester.getCenter(find.byType(Icon)));
|
||||
// The frame that starts it, then halfway.
|
||||
await tester.pump();
|
||||
await tester.pump(InstanceTile.duration ~/ 2);
|
||||
expect(ring(tester), inExclusiveRange(0, 1), reason: 'it fades in');
|
||||
await tester.pumpAndSettle();
|
||||
expect(tagline(tester), 1);
|
||||
expect(ring(tester), 1);
|
||||
|
||||
await mouse.moveTo(tester.getCenter(find.text('Elsewhere')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tagline(tester), 0);
|
||||
expect(ring(tester), 0);
|
||||
});
|
||||
|
||||
testWidgets("the keyboard's focus rings it, and Enter chooses it", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester);
|
||||
|
||||
// From Elsewhere to the tile.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
expect(ring(tester), 1);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
expect(tapped, ['tile']);
|
||||
});
|
||||
|
||||
testWidgets('it rings while pressed', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
final finger = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Icon)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(ring(tester), 1);
|
||||
|
||||
await finger.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(ring(tester), 0);
|
||||
expect(tapped, ['tile']);
|
||||
});
|
||||
|
||||
testWidgets('it stays ringed while what it opens is open', (tester) async {
|
||||
await show(tester, expanded: true);
|
||||
expect(ring(tester), 1);
|
||||
|
||||
await show(tester, expanded: false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(ring(tester), 0);
|
||||
});
|
||||
|
||||
testWidgets('a screen reader hears one button, its tagline included', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, expanded: false);
|
||||
|
||||
expect(
|
||||
// The label's node is merged into the tile's.
|
||||
tester.getSemantics(find.text('learn.halkyon.eu')),
|
||||
matchesSemantics(
|
||||
label: 'learn.halkyon.eu\nComes with the app',
|
||||
isButton: true,
|
||||
hasTapAction: true,
|
||||
hasFocusAction: true,
|
||||
isFocusable: true,
|
||||
hasExpandedState: true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('with animations off, it rings at once', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
// The app's own MediaQuery would hide one above it.
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(disableAnimations: true),
|
||||
child: child!,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: InstanceTile(
|
||||
icon: const Icon(LucideIcons.globe),
|
||||
label: 'learn.halkyon.eu',
|
||||
tagline: 'Comes with the app',
|
||||
expanded: false,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.startGesture(tester.getCenter(find.byType(Icon)));
|
||||
await tester.pump();
|
||||
expect(ring(tester), 1);
|
||||
});
|
||||
}
|
||||
70
packages/halkyon_app/test/l10n/arb_files_test.dart
Normal file
70
packages/halkyon_app/test/l10n/arb_files_test.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// The messages of the `.arb` file [name] in `lib/l10n/`, without metadata.
|
||||
Map<String, String> _messages(String name) => {
|
||||
for (final MapEntry(:key, :value) in (jsonDecode(
|
||||
File('lib/l10n/$name').readAsStringSync(),
|
||||
) as Map<String, Object?>).entries)
|
||||
if (!key.startsWith('@')) key: value! as String,
|
||||
};
|
||||
|
||||
/// The names of the placeholders in [message], with plural and select
|
||||
/// arguments; the words of their branches don't count. (gen-l10n reads
|
||||
/// quotes as text here: `use-escaping` is off.)
|
||||
Set<String> _placeholders(String message) {
|
||||
final names = <String>{};
|
||||
// Even depths hold text and placeholders, odd ones a plural's branches.
|
||||
var depth = 0;
|
||||
for (var i = 0; i < message.length; i++) {
|
||||
switch (message[i]) {
|
||||
case '{':
|
||||
final name = RegExp(r'\{\s*(\w+)\s*[,}]').matchAsPrefix(message, i);
|
||||
if (name != null && depth.isEven) {
|
||||
names.add(name.group(1)!);
|
||||
}
|
||||
depth++;
|
||||
case '}':
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final template = _messages('halkyon_app_en.arb');
|
||||
|
||||
test('German has every message, with the same placeholders', () {
|
||||
final german = _messages('halkyon_app_de.arb');
|
||||
|
||||
expect(
|
||||
template.keys.where((key) => !german.containsKey(key)),
|
||||
isEmpty,
|
||||
reason: 'untranslated',
|
||||
);
|
||||
expect(
|
||||
german.keys.where((key) => !template.containsKey(key)),
|
||||
isEmpty,
|
||||
reason: 'not in the template',
|
||||
);
|
||||
for (final MapEntry(:key, :value) in german.entries) {
|
||||
expect(_placeholders(value), _placeholders(template[key]!), reason: key);
|
||||
}
|
||||
});
|
||||
|
||||
test("the English variants override the template's messages only, with "
|
||||
'the same placeholders', () {
|
||||
for (final variant in ['halkyon_app_en_GB.arb', 'halkyon_app_en_US.arb']) {
|
||||
for (final MapEntry(:key, :value) in _messages(variant).entries) {
|
||||
expect(template, contains(key), reason: '$variant: $key');
|
||||
expect(
|
||||
_placeholders(value),
|
||||
_placeholders(template[key]!),
|
||||
reason: '$variant: $key',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
test('speaks British English, American English and German', () {
|
||||
expect(
|
||||
HalkyonAppLocalizations.supportedLocales,
|
||||
containsAll(const [
|
||||
Locale('en'),
|
||||
Locale('en', 'GB'),
|
||||
Locale('en', 'US'),
|
||||
Locale('de'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("names the app it is part of where its texts do, as Halkyon Learn's "
|
||||
'own texts did', () {
|
||||
final english = lookupHalkyonAppLocalizations(const Locale('en'));
|
||||
final german = lookupHalkyonAppLocalizations(const Locale('de'));
|
||||
|
||||
expect(
|
||||
english.instanceWelcome('Halkyon Learn'),
|
||||
'Welcome to Halkyon Learn',
|
||||
);
|
||||
expect(
|
||||
german.instanceWelcome('Halkyon Learn'),
|
||||
'Willkommen bei Halkyon Learn',
|
||||
);
|
||||
expect(
|
||||
english.instanceUnreachable('Halkyon Learn', 'learn.example.org'),
|
||||
'No instance of Halkyon Learn answers at learn.example.org.',
|
||||
);
|
||||
expect(
|
||||
german.instanceUnreachable('Halkyon Learn', 'learn.example.org'),
|
||||
'Unter learn.example.org antwortet keine Instanz von Halkyon Learn.',
|
||||
);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue