Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
205 lines
6.5 KiB
Dart
205 lines
6.5 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
import '../../support/fake_auth_gateway.dart';
|
|
import '../../support/fake_file_picker.dart';
|
|
import '../../support/fake_profile_gateway.dart';
|
|
import '../../support/pump_app.dart';
|
|
import '../../support/test_dependencies.dart';
|
|
|
|
void main() {
|
|
/// Opens the Profile tab of a person with an identity, whose profile the
|
|
/// instance answers through [profiles].
|
|
Future<void> showProfile(
|
|
WidgetTester tester,
|
|
FakeProfileGateway profiles, {
|
|
bool identified = true,
|
|
FakeFilePicker? files,
|
|
}) async {
|
|
tester.view.physicalSize = const Size(1000, 1800);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
await pumpApp(
|
|
tester,
|
|
testDependencies(
|
|
authGateway: FakeAuthGateway(
|
|
status: SignedIn(
|
|
authUserId: 'github-person',
|
|
isIdentified: identified,
|
|
),
|
|
),
|
|
profileGateway: profiles,
|
|
files: files,
|
|
),
|
|
location: RoutePaths.profile,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
|
|
|
testWidgets('a person with an identity writes a public profile and saves '
|
|
'it, then sees the address others find them by', (tester) async {
|
|
final profiles = FakeProfileGateway();
|
|
await showProfile(tester, profiles);
|
|
|
|
expect(find.text('Public profile'), findsOneWidget);
|
|
await tester.enterText(field('Display name'), 'Tizian');
|
|
await tester.enterText(field('Handle'), 'raisondetredev');
|
|
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
|
await tester.tap(find.text('Add link'));
|
|
await tester.pumpAndSettle();
|
|
await tester.enterText(
|
|
find.widgetWithText(TextField, 'https://…'),
|
|
'https://halkyon.app',
|
|
);
|
|
await tester.tap(find.text('Save'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Profile saved.'), findsOneWidget);
|
|
expect(
|
|
find.text('Others find you as @raisondetredev@halkyon.eu'),
|
|
findsOneWidget,
|
|
);
|
|
expect(profiles.profile!.links, ['https://halkyon.app']);
|
|
expect(profiles.profile!.displayName, 'Tizian');
|
|
});
|
|
|
|
testWidgets('a handle someone else has is refused, said under it', (
|
|
tester,
|
|
) async {
|
|
await showProfile(tester, FakeProfileGateway(taken: {'Tizian'}));
|
|
|
|
await tester.enterText(field('Handle'), 'tizian');
|
|
await tester.tap(find.text('Save'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('once its person has published, the handle is fixed and the '
|
|
'profile stays', (tester) async {
|
|
await showProfile(
|
|
tester,
|
|
FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
handleFixed: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
find.text('Fixed since you published: others refer to it.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
tester.widget<TextField>(field('Handle')).readOnly,
|
|
isTrue,
|
|
);
|
|
expect(find.text('Remove profile'), findsNothing);
|
|
});
|
|
|
|
testWidgets('removing a profile asks first, then frees its handle', (
|
|
tester,
|
|
) async {
|
|
final profiles = FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
),
|
|
);
|
|
await showProfile(tester, profiles);
|
|
|
|
await tester.tap(find.text('Remove profile'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Remove your public profile?'), findsOneWidget);
|
|
await tester.tap(find.text('Remove profile').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Profile removed.'), findsOneWidget);
|
|
expect(profiles.profile, isNull);
|
|
});
|
|
|
|
testWidgets('a saved profile takes a picture as its avatar, and gives it '
|
|
'up', (tester) async {
|
|
final profiles = FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
),
|
|
);
|
|
final files = FakeFilePicker()
|
|
..next = (name: 'me.jpg', bytes: Uint8List.fromList([1, 2, 3]));
|
|
await showProfile(tester, profiles, files: files);
|
|
|
|
await tester.tap(find.text('Choose picture'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(profiles.avatars.single, [1, 2, 3]);
|
|
expect(files.asked.single, contains('jpg'));
|
|
expect(find.text('Profile saved.'), findsOneWidget);
|
|
await tester.tap(find.text('Remove picture'));
|
|
await tester.pumpAndSettle();
|
|
expect(profiles.profile!.avatarUrl, isNull);
|
|
expect(find.text('Remove picture'), findsNothing);
|
|
});
|
|
|
|
testWidgets('a profile not yet saved has no picture to choose', (
|
|
tester,
|
|
) async {
|
|
await showProfile(tester, FakeProfileGateway());
|
|
|
|
expect(find.text('Save your profile to add a picture.'), findsOneWidget);
|
|
expect(find.text('Choose picture'), findsNothing);
|
|
});
|
|
|
|
testWidgets('an anonymous person is told how to have one', (tester) async {
|
|
await showProfile(tester, FakeProfileGateway(), identified: false);
|
|
|
|
expect(
|
|
find.text('Sign in with GitHub to have a public profile and publish.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Public profile'), findsNothing);
|
|
});
|
|
|
|
testWidgets('without the instance, it says so and tries again', (
|
|
tester,
|
|
) async {
|
|
final profiles = FakeProfileGateway()..fail = Exception('offline');
|
|
await showProfile(tester, profiles);
|
|
|
|
expect(
|
|
find.text("Your public profile can't be reached right now."),
|
|
findsOneWidget,
|
|
);
|
|
profiles.fail = null;
|
|
await tester.tap(find.text('Retry'));
|
|
await tester.pumpAndSettle();
|
|
expect(field('Handle'), findsOneWidget);
|
|
});
|
|
|
|
test(
|
|
'the repository leaves blank links out and trims what it saves',
|
|
() async {
|
|
final profiles = FakeProfileGateway();
|
|
await ProfileRepository(profiles).save(
|
|
handle: ' tizian ',
|
|
displayName: ' Tizian ',
|
|
bio: '',
|
|
links: ['', ' https://halkyon.app '],
|
|
);
|
|
|
|
expect(profiles.profile!.handle, 'tizian');
|
|
expect(profiles.profile!.links, ['https://halkyon.app']);
|
|
},
|
|
);
|
|
}
|