feat(app,learn): invite to a shared studyset by email or link, and join
The apps' half of ADR 0044's step 5 (D37 to D40), on the instance's
half of the commit before.
halkyon_app's SharedSpaceGateway asks for inviting by mail, the
preview of a link, the code proving an address, accepting a mailed
invitation, a space's link (read, switched, joined) and marking a
share seen; SpaceInfo says whether a share is new and who made it,
a member whether they were invited by mail. joinTokenOf reads a link
or its token as pasted. The fake keeps the mails and codes it would
send, for the apps' tests.
In Learn:
- the Owner's Share sheet invites by handle or by email, lists an
invitation mailed by its role alone, and switches the space's link,
copying it while on;
- the Library's menu has Join with a link, and a link opened on
Android (/join/<token> on learn.halkyon.eu, verified through the
instance's assetlinks) opens the same on a screen of its own: who
invites and as whom, then Join, or for a mailed invitation the
address and the code mailed there;
- a studyset someone shared is new until opened: in the Inbox
("@alice shared Japanese with you", Open or Leave), on the Profile
tab's badge and as New on its library card (NewSharesCubit, read
again after every merge).
Whats-New: Invite people to a shared studyset by email or with a link, join one from the Library's Join with a link, and see in the Inbox what was shared with you.
Whats-New-De: Lade Leute per E-Mail oder Link zu einem geteilten Lernset ein, tritt einem über „Mit einem Link beitreten“ in der Bibliothek bei und sieh im Posteingang, was mit dir geteilt wurde.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
554e1604b4
commit
fb6b78fa19
4 changed files with 2443 additions and 54 deletions
|
|
@ -0,0 +1,225 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
/// Inviting by mail and by link, joining, and what is new (Halkyon's ADR
|
||||
/// 0044, D37 to D40), on the screens: the Owner's Share sheet, the
|
||||
/// Library's Join with a link, a link the app opens, the Inbox and the
|
||||
/// library's card.
|
||||
void main() {
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeSharedSpaceServer spaces;
|
||||
late String space;
|
||||
|
||||
setUp(() {
|
||||
studysets = FakeStudysetGateway();
|
||||
spaces = FakeSharedSpaceServer(FakeSharedKeysServer())
|
||||
..addPerson('owner', handle: 'owner')
|
||||
..addPerson('person', handle: 'person');
|
||||
});
|
||||
|
||||
/// Japanese, in [space] where it is shared.
|
||||
void japanese({String? sharedIn, bool viewOnly = false}) {
|
||||
studysets.receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
),
|
||||
);
|
||||
if (sharedIn != null) {
|
||||
studysets.shared['japanese'] = (space: sharedIn, viewOnly: viewOnly);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> show(WidgetTester tester, String location) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
authGateway: FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'person', isIdentified: true),
|
||||
),
|
||||
studysetGateway: studysets,
|
||||
sharedSpaces: spaces.as('person'),
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> openShare(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Share'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('the Owner invites by mail, and the invitation shows by email '
|
||||
'alone', (tester) async {
|
||||
space = await spaces.as('person').create('studyset');
|
||||
japanese(sharedIn: space);
|
||||
await show(tester, RoutePaths.studyset('japanese'));
|
||||
await openShare(tester);
|
||||
|
||||
await tester.tap(find.text('Invite'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Email'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Email address'),
|
||||
'dana@example.org',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Invite').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(spaces.mailed.single.address, 'dana@example.org');
|
||||
expect(find.text('Invitation mailed to dana@example.org'), findsOneWidget);
|
||||
expect(find.text('Invited by email'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the Owner switches the link on, and copies it', (tester) async {
|
||||
space = await spaces.as('person').create('studyset');
|
||||
japanese(sharedIn: space);
|
||||
await show(tester, RoutePaths.studyset('japanese'));
|
||||
await openShare(tester);
|
||||
|
||||
expect(find.text('Copy link'), findsNothing);
|
||||
await tester.tap(find.text('Join by link'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Copy link'), findsOneWidget);
|
||||
expect(await spaces.as('person').joinLink(space), isNotNull);
|
||||
});
|
||||
|
||||
testWidgets("a member who isn't the Owner sees no link to switch", (
|
||||
tester,
|
||||
) async {
|
||||
space = await spaces.as('owner').create('studyset');
|
||||
await spaces.as('owner').invite(space, 'person', SpaceRole.editor);
|
||||
japanese(sharedIn: space);
|
||||
await show(tester, RoutePaths.studyset('japanese'));
|
||||
await openShare(tester);
|
||||
|
||||
expect(find.text('Join by link'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("the Library joins by a space's link pasted", (tester) async {
|
||||
space = await spaces.as('owner').create('studyset');
|
||||
final link = (await spaces.as('owner').setJoinLink(space, on: true))!;
|
||||
await show(tester, RoutePaths.library);
|
||||
|
||||
await tester.tap(find.byTooltip('Library options'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Join with a link'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Link or code'),
|
||||
link,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('@owner wants to share a studyset with you'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('You can view and study it.'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'You joined. The studyset arrives in your Library with the next sync.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect((await spaces.as('person').mine()).single.id, space);
|
||||
});
|
||||
|
||||
testWidgets('a mailed link the app opens asks for the address and its '
|
||||
'code', (tester) async {
|
||||
space = await spaces.as('owner').create('studyset');
|
||||
await spaces
|
||||
.as('owner')
|
||||
.inviteByEmail(
|
||||
space,
|
||||
'dana@example.org',
|
||||
SpaceRole.editor,
|
||||
'en',
|
||||
);
|
||||
await show(tester, '/join/${spaces.mailed.single.token}');
|
||||
|
||||
expect(
|
||||
find.text('@owner wants to share a studyset with you'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Email address'),
|
||||
'dana@example.org',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Send code'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('A code is on its way to dana@example.org.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Code from the mail'),
|
||||
'WRONG',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("That code isn't right."), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Code from the mail'),
|
||||
spaces.codeFor('dana@example.org'),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||
await tester.pumpAndSettle();
|
||||
expect((await spaces.as('person').mine()).single.role, SpaceRole.editor);
|
||||
});
|
||||
|
||||
testWidgets('a studyset shared with the person is New on its card and in '
|
||||
'the Inbox until they open it', (tester) async {
|
||||
space = await spaces.as('owner').create('studyset');
|
||||
await spaces.as('owner').invite(space, 'person', SpaceRole.viewer);
|
||||
japanese(sharedIn: space, viewOnly: true);
|
||||
await show(tester, RoutePaths.profile);
|
||||
|
||||
expect(find.text('Shared with you'), findsOneWidget);
|
||||
expect(find.text('@owner shared Japanese with you'), findsOneWidget);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect((await spaces.as('person').mine()).single.isNew, isFalse);
|
||||
await tester.tap(find.text('Library').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('New'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("the library's card says New before it is opened", (
|
||||
tester,
|
||||
) async {
|
||||
space = await spaces.as('owner').create('studyset');
|
||||
await spaces.as('owner').invite(space, 'person', SpaceRole.viewer);
|
||||
japanese(sharedIn: space, viewOnly: true);
|
||||
await show(tester, RoutePaths.library);
|
||||
|
||||
expect(find.text('New'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/join_space_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/new_shares_cubit.dart';
|
||||
|
||||
/// Joining by a link and what is new to whom (Halkyon's ADR 0044, D37 to
|
||||
/// D40), against the fake instance: a link joins at once, an invitation
|
||||
/// mailed only with a code to its address; a share stays new until seen.
|
||||
void main() {
|
||||
late FakeSharedSpaceServer server;
|
||||
late String space;
|
||||
late int syncs;
|
||||
|
||||
setUp(() async {
|
||||
server = FakeSharedSpaceServer(FakeSharedKeysServer())
|
||||
..addPerson('owner', handle: 'owner')
|
||||
..addPerson('person', handle: 'person');
|
||||
space = await server.as('owner').create('studyset');
|
||||
syncs = 0;
|
||||
});
|
||||
|
||||
JoinSpaceCubit joining() {
|
||||
final cubit = JoinSpaceCubit(server.as('person'), () => syncs++);
|
||||
addTearDown(cubit.close);
|
||||
return cubit;
|
||||
}
|
||||
|
||||
test("a space's link, pasted, is looked up and joined as a Viewer, and a "
|
||||
'sync brings it', () async {
|
||||
final link = (await server.as('owner').setJoinLink(space, on: true))!;
|
||||
final cubit = joining();
|
||||
|
||||
await cubit.look(' $link ');
|
||||
expect(cubit.state.preview!.byEmail, isFalse);
|
||||
expect(cubit.state.preview!.inviterHandle, 'owner');
|
||||
await cubit.join();
|
||||
|
||||
expect(cubit.state.joined, isTrue);
|
||||
expect(syncs, 1);
|
||||
expect((await server.as('person').mine()).single.role, SpaceRole.viewer);
|
||||
});
|
||||
|
||||
test('a link that holds no more is said so, and nothing joins', () async {
|
||||
final cubit = joining();
|
||||
await cubit.look('https://learn.example.org/join/gone');
|
||||
expect(cubit.state.problem, SpaceRefusal.notFound);
|
||||
expect(cubit.state.found, isFalse);
|
||||
expect(await server.as('person').mine(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'an invitation mailed joins only once a code proves its address',
|
||||
() async {
|
||||
await server
|
||||
.as('owner')
|
||||
.inviteByEmail(
|
||||
space,
|
||||
'dana@example.org',
|
||||
SpaceRole.editor,
|
||||
'en',
|
||||
);
|
||||
final cubit = joining();
|
||||
await cubit.look(server.mailed.single.token);
|
||||
expect(cubit.state.preview!.byEmail, isTrue);
|
||||
|
||||
await cubit.sendCode('person@example.org', language: 'en');
|
||||
await cubit.join(code: server.codeFor('person@example.org'));
|
||||
expect(cubit.state.problem, SpaceRefusal.otherAddress);
|
||||
expect(cubit.state.joined, isFalse);
|
||||
|
||||
await cubit.sendCode('dana@example.org', language: 'en');
|
||||
expect(cubit.state.address, 'dana@example.org');
|
||||
await cubit.join(code: 'WRONG');
|
||||
expect(cubit.state.problem, SpaceRefusal.wrongCode);
|
||||
|
||||
await cubit.join(code: server.codeFor('dana@example.org'));
|
||||
expect(cubit.state.problem, isNull);
|
||||
expect(cubit.state.joined, isTrue);
|
||||
expect((await server.as('person').mine()).single.role, SpaceRole.editor);
|
||||
},
|
||||
);
|
||||
|
||||
test('a space shared with the person is new until seen, read again after '
|
||||
'each merge, and left from there', () async {
|
||||
final merges = StreamController<void>.broadcast();
|
||||
addTearDown(merges.close);
|
||||
final cubit = NewSharesCubit(server.as('person'), merges.stream);
|
||||
addTearDown(cubit.close);
|
||||
await cubit.refresh();
|
||||
expect(cubit.state, isEmpty);
|
||||
|
||||
await server.as('owner').invite(space, 'person', SpaceRole.viewer);
|
||||
merges.add(null);
|
||||
await pumpEventQueue();
|
||||
expect(cubit.isNew(space), isTrue);
|
||||
expect(cubit.state.single.sharedBy, 'owner');
|
||||
|
||||
await cubit.seen(space);
|
||||
expect(cubit.isNew(space), isFalse);
|
||||
await cubit.refresh();
|
||||
expect(cubit.state, isEmpty);
|
||||
|
||||
final other = await server.as('owner').create('studyset');
|
||||
await server.as('owner').invite(other, 'person', SpaceRole.viewer);
|
||||
await cubit.refresh();
|
||||
expect(await cubit.leave(other), isTrue);
|
||||
expect(cubit.state, isEmpty);
|
||||
expect((await server.as('person').mine()).map((s) => s.id), [space]);
|
||||
});
|
||||
}
|
||||
|
|
@ -131,9 +131,13 @@ void main() {
|
|||
);
|
||||
expect(await cubit.leave(nextOwner: bob), isA<SpaceActionDone>());
|
||||
expect(await server.as('alice').mine(), isEmpty);
|
||||
expect(await server.as('bob').mine(), [
|
||||
(id: space, kind: 'studyset', role: SpaceRole.owner),
|
||||
]);
|
||||
expect(
|
||||
[
|
||||
for (final shared in await server.as('bob').mine())
|
||||
(shared.id, shared.role),
|
||||
],
|
||||
[(space, SpaceRole.owner)],
|
||||
);
|
||||
});
|
||||
|
||||
test('a space out of reach is said so', () async {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart'
|
||||
show SharedSpaceGateway, SpaceInfo, SpaceMember, SpaceRole;
|
||||
show
|
||||
SharedSpaceGateway,
|
||||
SpaceInfo,
|
||||
SpaceJoinPreview,
|
||||
SpaceMember,
|
||||
SpaceRole;
|
||||
import 'package:halkyon_app/testing.dart'
|
||||
show FakeFileServerGateway, MemoryFileCache;
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
|
|
@ -338,6 +343,41 @@ class _Spaces() implements SharedSpaceGateway {
|
|||
@override
|
||||
Future<void> leave(String space, {String? nextOwner}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<SpaceMember> inviteByEmail(
|
||||
String space,
|
||||
String address,
|
||||
SpaceRole role,
|
||||
String language,
|
||||
) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<SpaceJoinPreview> preview(String token) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<String> sendJoinCode(String address, {required String language}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<SpaceInfo> acceptEmailInvitation(
|
||||
String token, {
|
||||
required String request,
|
||||
required String code,
|
||||
}) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<String?> joinLink(String space) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<String?> setJoinLink(String space, {required bool on}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<SpaceInfo> joinByLink(String token) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> markSeen(String space) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// The device's side of a new space: the spaces readied and the studysets
|
||||
|
|
|
|||
Loading…
Reference in a new issue