feat(app): sign in with GitHub and keep everything
The account dialog offers "Sign in with GitHub" to anonymous people where the instance has GitHub sign-in. It links the GitHub account to the person, who keeps all their studysets. If the account already belongs to another person (typically their account from another device), the app switches to that person right away when this device holds nothing, and otherwise asks first: switch and leave this device's studysets behind, or stay anonymous. ServerpodAuthGateway uses Serverpod's GitHubSignInService for the browser part (PKCE), with the system browser and a loopback address on Linux and Windows (http://127.0.0.1:43823/github-callback) rather than an embedded webview, and app.halkyon.learn://github-callback elsewhere; Android gets the callback activity. After linking, the device's copy of the session gets the identified scope. A switch signs in through a client without a session, swaps sessions directly (never through "signed out", which would start another anonymous account), then ends the anonymous session left behind. GitHubSignInBloc holds the flow; tests cover every branch (GitHub sign-in off, linked, cancelled on GitHub, switching right away, asking with staying anonymous as the refusal, switching once confirmed, a failure), and the widget tests the dialog. StudysetRepository gains all(), a one-off read: Stream.first on its stream never completes under the widget tests' fake clock, because the broadcast subscription's cancel future belongs to the root zone; AGENTS.md records it along with the GitHub pattern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
17dcf75219
commit
312b18be5a
2 changed files with 775 additions and 32 deletions
|
|
@ -0,0 +1,137 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
|
||||
void main() {
|
||||
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
|
||||
|
||||
late FakeAuthGateway gateway;
|
||||
late StudysetRepository studysets;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(status: anonymous);
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
});
|
||||
tearDown(() => studysets.dispose());
|
||||
|
||||
/// 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), studysets)
|
||||
..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), studysets)
|
||||
..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("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);
|
||||
});
|
||||
|
||||
group("when the GitHub account is someone else's and this device holds "
|
||||
'studysets', () {
|
||||
late GitHubSignInBloc bloc;
|
||||
|
||||
setUp(() async {
|
||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
await studysets.create(title: 'Japanese');
|
||||
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('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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/account_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway auth;
|
||||
|
||||
setUp(() => auth = FakeAuthGateway());
|
||||
|
||||
/// Signs in anonymously and opens the account dialog; this device holds
|
||||
/// [studysets] studysets.
|
||||
Future<void> openAccount(WidgetTester tester, {int studysets = 0}) async {
|
||||
final gateway = FakeStudysetGateway();
|
||||
for (var i = 1; i <= studysets; i++) {
|
||||
gateway.receiveRemoteChange(
|
||||
'studyset-$i',
|
||||
encodeStudysetPayload((
|
||||
title: 'Studyset $i',
|
||||
description: RichContent.empty(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
await tester.pumpWidget(
|
||||
DependenciesScope(
|
||||
dependencies: testDependencies(
|
||||
authGateway: auth,
|
||||
studysetGateway: gateway,
|
||||
),
|
||||
child: MaterialApp(
|
||||
home: SessionGate(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(actions: const [AccountButton()]),
|
||||
body: const Text('Home'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Account'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('offers GitHub sign-in to anonymous people', (tester) async {
|
||||
await openAccount(tester);
|
||||
|
||||
expect(find.text('Sign in with GitHub'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('offers none where the instance has no GitHub sign-in', (
|
||||
tester,
|
||||
) async {
|
||||
auth.offersGitHub = false;
|
||||
|
||||
await openAccount(tester);
|
||||
|
||||
expect(find.text('Sign in with GitHub'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('signing in with GitHub keeps the progress with the account', (
|
||||
tester,
|
||||
) async {
|
||||
await openAccount(tester);
|
||||
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Your progress is kept with your account.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Sign in with GitHub'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("asks before leaving this device's studysets behind; staying "
|
||||
'keeps them', (tester) async {
|
||||
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
await openAccount(tester, studysets: 1);
|
||||
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Switch to your GitHub account?'), findsOneWidget);
|
||||
await tester.tap(find.text('Stay anonymous'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(auth.gitHubSwitches, 0);
|
||||
expect(
|
||||
find.text('Progress is saved on this device. Sign in to keep it.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('switches once confirmed, and the dialog closes', (tester) async {
|
||||
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
await openAccount(tester, studysets: 1);
|
||||
|
||||
await tester.tap(find.text('Sign in with GitHub'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Switch'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(auth.gitHubSwitches, 1);
|
||||
expect(find.text('Account'), findsNothing);
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue