diff --git a/test/app/lazurite_app_test.dart b/test/app/lazurite_app_test.dart --- a/test/app/lazurite_app_test.dart +++ b/test/app/lazurite_app_test.dart @@ -21,6 +21,7 @@ import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/app/lazurite_app.dart'; import 'package:mocktail/mocktail.dart'; +import '../helpers/connectivity_helpers.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} @@ -75,12 +76,7 @@ when(() => settingsCubit.state).thenReturn(settingsState); whenListen(settingsCubit, const Stream.empty(), initialState: settingsState); when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); - when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); - whenListen( - connectivityCubit, - const Stream.empty(), - initialState: const ConnectivityState.online(), - ); + stubConnectivityCubit(connectivityCubit, state: const ConnectivityState.online()); when(() => connectivityCubit.setSimulatedOffline(any())).thenReturn(null); when(() => connectivityCubit.close()).thenAnswer((_) async {}); when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); diff --git a/test/helpers/assertion_helpers.dart b/test/helpers/assertion_helpers.dart new file mode 100644 --- /dev/null +++ b/test/helpers/assertion_helpers.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; + +void expectAccountRow({required String handle, String? displayName}) { + if (displayName != null) { + expect(find.text(displayName), findsOneWidget); + } + expect(find.text('@$handle'), findsOneWidget); +} + +void expectErrorState(String title, {String? message, Finder? retryFinder}) { + expect(find.text(title), findsOneWidget); + if (message != null) { + expect(find.text(message), findsOneWidget); + } + expect(retryFinder ?? find.text('Retry'), findsOneWidget); +} + +Future tapRetry(WidgetTester tester, {Finder? retryFinder}) async { + await tester.tap(retryFinder ?? find.text('Retry')); + await tester.pumpAndSettle(); +} + +void expectOfflineState(String title, {String? message}) { + expect(find.text(title), findsOneWidget); + if (message != null) { + expect(find.text(message), findsOneWidget); + } +} + +void expectListDetailHeader({required String description, required String creatorHandle}) { + expect(find.text(description), findsOneWidget); + expect(find.text('by @$creatorHandle'), findsOneWidget); +} + +Future tapMembersTab(WidgetTester tester) async { + await tester.tap(find.text('MEMBERS')); + await tester.pumpAndSettle(); +} + +void expectListMember({required String handle, String? displayName}) => + expectAccountRow(handle: handle, displayName: displayName); + +void expectFeedEmbed({required String name, String? description, String? likeCount}) { + expect(find.text('FEED'), findsOneWidget); + expect(find.text(name), findsOneWidget); + if (description != null) expect(find.text(description), findsOneWidget); + if (likeCount != null) expect(find.text(likeCount), findsOneWidget); +} + +void expectListEmbed({required String name, String? description}) { + expect(find.text('LIST'), findsOneWidget); + expect(find.text(name), findsOneWidget); + if (description != null) expect(find.text(description), findsOneWidget); +} diff --git a/test/helpers/auth_fixtures.dart b/test/helpers/auth_fixtures.dart deleted file mode 100644 --- a/test/helpers/auth_fixtures.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:lazurite/features/auth/data/models/auth_models.dart'; - -AuthTokens testAuthTokens({ - String accessToken = 'access-token', - String? refreshToken = 'refresh-token', - String did = 'did:plc:test', - String handle = 'test.bsky.social', - String? displayName, - String? service = 'bsky.social', - DateTime? expiresAt, - String? oauthService, - String? oauthClientId, - String? oauthTokenType, - String? oauthScope, - String? dpopNonce, - String? dpopPublicKey, - String? dpopPrivateKey, - AuthMethod authMethod = AuthMethod.appPassword, -}) => AuthTokens( - accessToken: accessToken, - refreshToken: refreshToken, - expiresAt: expiresAt ?? DateTime.now().toUtc().add(const Duration(hours: 1)), - did: did, - handle: handle, - displayName: displayName, - service: service, - oauthService: oauthService, - oauthClientId: oauthClientId, - oauthTokenType: oauthTokenType, - oauthScope: oauthScope, - dpopNonce: dpopNonce, - dpopPublicKey: dpopPublicKey, - dpopPrivateKey: dpopPrivateKey, - authMethod: authMethod, -); diff --git a/test/helpers/connectivity_helpers.dart b/test/helpers/connectivity_helpers.dart new file mode 100644 --- /dev/null +++ b/test/helpers/connectivity_helpers.dart @@ -0,0 +1,12 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:mocktail/mocktail.dart'; + +void stubConnectivityCubit( + ConnectivityCubit cubit, { + ConnectivityState state = const ConnectivityState.online(), + Stream stream = const Stream.empty(), +}) { + when(() => cubit.state).thenReturn(state); + whenListen(cubit, stream, initialState: state); +} diff --git a/test/helpers/feed_fixtures.dart b/test/helpers/feed_fixtures.dart deleted file mode 100644 --- a/test/helpers/feed_fixtures.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:bluesky_poptart/app/bsky/actor/defs.dart' hide ViewerState; -import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; -import 'package:poptart_core/poptart_core.dart'; - -const testAuthorDid = 'did:plc:author'; -const testAuthorHandle = 'author.bsky.social'; -const testPostUri = 'at://did:plc:author/app.bsky.feed.post/abc'; - -ProfileViewBasic testProfileViewBasic({ - String did = testAuthorDid, - String handle = testAuthorHandle, - String? displayName, - String? avatar, -}) => ProfileViewBasic(did: did, handle: handle, displayName: displayName, avatar: avatar); - -Map testPostRecordJson({ - String text = 'Test post', - DateTime? createdAt, - Map extra = const {}, -}) => { - r'$type': 'app.bsky.feed.post', - 'text': text, - 'createdAt': (createdAt ?? DateTime.utc(2026, 3, 15)).toUtc().toIso8601String(), - ...extra, -}; - -PostView testPostView({ - String uri = testPostUri, - String? cid, - ProfileViewBasic? author, - Map? record, - DateTime? indexedAt, - int? replyCount, - int? repostCount, - int? likeCount, - int? quoteCount, - UPostViewEmbed? embed, -}) => PostView( - uri: AtUri.parse(uri), - cid: cid ?? 'cid-${uri.hashCode}', - author: author ?? testProfileViewBasic(), - record: record ?? testPostRecordJson(), - indexedAt: indexedAt ?? DateTime.utc(2026, 3, 15), - replyCount: replyCount, - repostCount: repostCount, - likeCount: likeCount, - quoteCount: quoteCount, - embed: embed, -); - -FeedViewPost testFeedViewPost({ - String uri = testPostUri, - String? cid, - ProfileViewBasic? author, - Map? record, - DateTime? indexedAt, - int? replyCount, - int? repostCount, - int? likeCount, - int? quoteCount, - UPostViewEmbed? embed, -}) => FeedViewPost( - post: testPostView( - uri: uri, - cid: cid, - author: author, - record: record, - indexedAt: indexedAt, - replyCount: replyCount, - repostCount: repostCount, - likeCount: likeCount, - quoteCount: quoteCount, - embed: embed, - ), -); diff --git a/test/helpers/notification_fixtures.dart b/test/helpers/notification_fixtures.dart deleted file mode 100644 --- a/test/helpers/notification_fixtures.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; -import 'package:bluesky_poptart/app/bsky/notification/list_notifications.dart' as bsky; -import 'package:poptart_core/poptart_core.dart'; - -bsky.Notification testNotification({ - bsky.KnownNotificationReason reason = bsky.KnownNotificationReason.like, - Object uri = 'at://did:plc:author/app.bsky.feed.post/abc', - String cid = 'cid-123', - ProfileView author = const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), - AtUri? reasonSubject, - Map record = const {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, - bool isRead = false, - DateTime? indexedAt, -}) => bsky.Notification( - uri: uri is AtUri ? uri : AtUri.parse(uri as String), - cid: cid, - author: author, - reason: bsky.NotificationReason.knownValue(data: reason), - reasonSubject: reasonSubject, - record: record, - isRead: isRead, - indexedAt: indexedAt ?? DateTime.utc(2026, 3, 15), -); diff --git a/test/helpers/router_harness.dart b/test/helpers/router_harness.dart --- a/test/helpers/router_harness.dart +++ b/test/helpers/router_harness.dart @@ -87,7 +87,44 @@ Widget child = const Scaffold(body: Text('post')), }) => capturedRoute(path: '/post', onRoute: onRoute, child: child); -GoRoute loginRoute({Widget child = const Scaffold(body: Text('login'))}) => GoRoute( - path: '/login', - builder: (context, state) => child, -); +GoRoute loginRoute({Widget child = const Scaffold(body: Text('login'))}) => + GoRoute(path: '/login', builder: (context, state) => child); + +GoRoute settingsRoute({Widget child = const Scaffold(body: Text('settings-route'))}) => + GoRoute(path: '/settings', builder: (context, state) => child); + +GoRoute termsRoute({Widget child = const Scaffold(body: Text('terms-route'))}) => + GoRoute(path: '/terms', builder: (context, state) => child); + +GoRoute privacyRoute({Widget child = const Scaffold(body: Text('privacy-route'))}) => + GoRoute(path: '/privacy', builder: (context, state) => child); + +GoRoute alertsRoute({Widget child = const Scaffold(body: Text('alerts-route'))}) => + GoRoute(path: '/alerts', builder: (context, state) => child); + +GoRoute listCaptureRoute({ + required void Function(Uri uri) onRoute, + Widget child = const Scaffold(body: Text('list')), +}) => capturedRoute(path: '/lists/:list', onRoute: onRoute, child: child); + +GoRoute starterPackCaptureRoute({ + required void Function(Uri uri) onRoute, + Widget child = const Scaffold(body: Text('starter-pack')), +}) => capturedRoute(path: '/starter-pack/:actor/:rkey', onRoute: onRoute, child: child); + +GoRoute feedCaptureRoute({ + required void Function(Uri uri) onRoute, + Widget child = const Scaffold(body: Text('feed')), +}) => capturedRoute(path: '/feed/:feed', onRoute: onRoute, child: child); + +Future pumpRouteHarness( + WidgetTester tester, { + required Widget home, + String initialLocation = '/', + List routes = const [], + bool settle = true, +}) async { + final harness = TestRouterHarness(home: home, initialLocation: initialLocation, routes: routes); + await harness.pump(tester, settle: settle); + return harness; +} diff --git a/test/helpers/search_helpers.dart b/test/helpers/search_helpers.dart new file mode 100644 --- /dev/null +++ b/test/helpers/search_helpers.dart @@ -0,0 +1,51 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:lazurite/features/search/data/post_search_filters.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; + +void stubSearchPosts(SearchRepository repository, {SearchPostsResult? result}) { + when( + () => repository.searchPosts( + query: any(named: 'query'), + sort: any(named: 'sort'), + filters: any(named: 'filters'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => result ?? SearchPostsResult(posts: const [])); +} + +void stubSearchPostsError(SearchRepository repository, Object error) { + when( + () => repository.searchPosts( + query: any(named: 'query'), + sort: any(named: 'sort'), + filters: any(named: 'filters'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(error); +} + +List captureSearchFilters(SearchRepository repository) { + final captured = verify( + () => repository.searchPosts( + query: any(named: 'query'), + sort: any(named: 'sort'), + filters: captureAny(named: 'filters'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).captured; + return captured.cast(); +} + +void stubTypeahead(SearchRepository repository, {List actors = const []}) { + when( + () => repository.searchActorsTypeahead( + query: any(named: 'query'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => actors); +} diff --git a/test/helpers/settings_fixtures.dart b/test/helpers/settings_fixtures.dart deleted file mode 100644 --- a/test/helpers/settings_fixtures.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:lazurite/core/theme/app_theme.dart'; -import 'package:lazurite/core/theme/feed_layout.dart'; -import 'package:lazurite/features/search/data/search_scope.dart'; -import 'package:lazurite/features/settings/bloc/settings_state.dart'; - -SettingsState testSettingsState({ - AppThemePalette themePalette = AppThemePalette.oxocarbon, - AppThemeVariant themeVariant = AppThemeVariant.dark, - bool useSystemTheme = false, - FeedLayout feedLayout = FeedLayout.comfortable, - bool animationsEnabled = true, - bool simulateOffline = false, - int? threadAutoCollapseDepth, - String constellationUrl = 'https://constellation.microcosm.blue', - bool semanticSearchEnabled = true, - SearchScope searchScope = SearchScope.both, - int semanticSearchMaxResults = 20, - String typeaheadProvider = 'bluesky', - String appViewProvider = 'bluesky', - bool crossProviderFallbackEnabled = false, - bool slingshotIdentityFallbackEnabled = false, - bool crashReportingEnabled = false, - bool crashReportingConsentPrompted = false, - int routingEpoch = 0, - String? appViewHealthSummary, - DateTime? appViewHealthCheckedAt, - bool appViewHealthRefreshing = false, - String? appViewLastFallback, - String? appViewLastError, -}) => SettingsState( - themePalette: themePalette, - themeVariant: themeVariant, - useSystemTheme: useSystemTheme, - feedLayout: feedLayout, - animationsEnabled: animationsEnabled, - simulateOffline: simulateOffline, - threadAutoCollapseDepth: threadAutoCollapseDepth, - constellationUrl: constellationUrl, - semanticSearchEnabled: semanticSearchEnabled, - searchScope: searchScope, - semanticSearchMaxResults: semanticSearchMaxResults, - typeaheadProvider: typeaheadProvider, - appViewProvider: appViewProvider, - crossProviderFallbackEnabled: crossProviderFallbackEnabled, - slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabled, - crashReportingEnabled: crashReportingEnabled, - crashReportingConsentPrompted: crashReportingConsentPrompted, - routingEpoch: routingEpoch, - appViewHealthSummary: appViewHealthSummary, - appViewHealthCheckedAt: appViewHealthCheckedAt, - appViewHealthRefreshing: appViewHealthRefreshing, - appViewLastFallback: appViewLastFallback, - appViewLastError: appViewLastError, -); diff --git a/test/helpers/shared_test_helpers_test.dart b/test/helpers/shared_test_helpers_test.dart new file mode 100644 --- /dev/null +++ b/test/helpers/shared_test_helpers_test.dart @@ -0,0 +1,141 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/features/search/data/post_search_filters.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:poptart_core/poptart_core.dart'; + +import 'assertion_helpers.dart'; +import 'connectivity_helpers.dart'; +import 'fixtures/auth.dart'; +import 'fixtures/graph.dart'; +import 'fixtures/network.dart'; +import 'fixtures/package_info.dart'; +import 'search_helpers.dart'; + +class _MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + +class _MockSearchRepository extends Mock implements SearchRepository {} + +void main() { + setUpAll(() { + registerFallbackValue(const PostSearchFilters()); + }); + + group('auth fixtures', () { + test('build common account token shapes', () { + expect(testAliceTokens().handle, 'alice.bsky.social'); + expect(testRiverTokens().displayName, 'River Tam'); + expect(testOAuthTokens().authMethod, AuthMethod.oauth); + expect(testOpaqueOAuthTokens().accessToken, 'opaque-access-token'); + expect(testPdsOAuthTokens(service: 'https://custom.pds').service, 'https://custom.pds'); + }); + }); + + group('network fixtures', () { + test('builds DID documents with PDS service entries', () { + expect(testPdsService(serviceEndpoint: 'https://pds.example')['type'], 'AtprotoPersonalDataServer'); + expect(testDidDocument(serviceEndpoint: 'https://pds.example')['service'], [ + {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://pds.example'}, + ]); + }); + }); + + group('package info fixture', () { + test('uses Lazurite defaults with overridable fields', () { + final info = testPackageInfo(buildNumber: '42'); + + expect(info.appName, 'Lazurite'); + expect(info.packageName, 'org.stormlightlabs.lazurite'); + expect(info.buildNumber, '42'); + }); + }); + + group('graph fixtures', () { + test('builds list, list item, and starter pack models', () { + final listUri = AtUri.parse('at://did:plc:test/app.bsky.graph.list/abc'); + + expect(testListView(uri: listUri, name: 'Good Accounts').uri, listUri); + expect(testListItemView().subject.handle, 'member.bsky.social'); + expect(testStarterPackViewBasic(name: 'Good Pack').record['name'], 'Good Pack'); + }); + }); + + group('search helpers', () { + test('stub search posts and capture filters', () async { + final repository = _MockSearchRepository(); + const filters = PostSearchFilters(author: 'alice.bsky.social'); + + stubSearchPosts(repository); + + final result = await repository.searchPosts(query: 'flutter', filters: filters); + + expect(result.posts, isEmpty); + expect(captureSearchFilters(repository), [filters]); + }); + + test('stub typeahead and errors', () async { + final repository = _MockSearchRepository(); + + stubTypeahead( + repository, + actors: const [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social')], + ); + expect(await repository.searchActorsTypeahead(query: 'ali'), hasLength(1)); + + stubSearchPostsError(repository, Exception('boom')); + expect(() => repository.searchPosts(query: 'flutter'), throwsException); + }); + }); + + group('connectivity helper', () { + test('stubs state and stream', () { + final cubit = _MockConnectivityCubit(); + + stubConnectivityCubit(cubit, state: const ConnectivityState.offline()); + + expect(cubit.state, const ConnectivityState.offline()); + }); + }); + + group('assertion helpers', () { + testWidgets('assert common account and state copy', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + child: Column( + children: [ + Text('Alice'), + Text('@alice.bsky.social'), + Text('Failed to load messages'), + Text('Retry'), + Text('No connection'), + Text('Reconnect to load messages.'), + Text('A curated list of posts'), + Text('by @creator.bsky.social'), + Text('A Member'), + Text('@member.bsky.social'), + Text('FEED'), + Text('News Feed'), + Text('LIST'), + Text('Good Accounts'), + ], + ), + ), + ), + ); + + expectAccountRow(displayName: 'Alice', handle: 'alice.bsky.social'); + expectErrorState('Failed to load messages'); + expectOfflineState('No connection', message: 'Reconnect to load messages.'); + expectListDetailHeader(description: 'A curated list of posts', creatorHandle: 'creator.bsky.social'); + expectListMember(displayName: 'A Member', handle: 'member.bsky.social'); + expectFeedEmbed(name: 'News Feed'); + expectListEmbed(name: 'Good Accounts'); + }); + }); +} diff --git a/test/helpers/test_utils.dart b/test/helpers/test_utils.dart deleted file mode 100644 --- a/test/helpers/test_utils.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'dart:convert'; - -import 'package:poptart_core/poptart_core.dart' as atcore; - -export 'auth_fixtures.dart'; - -String base64UrlEncode(Map value) => - base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); - -String buildJwt({ - required String sub, - String? aud, - String? clientId, - String? iss, - String? scope, - int? expEpochSeconds, - int? iatEpochSeconds, -}) { - final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; - final header = base64UrlEncode(const {'alg': 'none', 'typ': 'JWT'}); - final payload = base64UrlEncode({ - 'sub': sub, - 'exp': expEpochSeconds ?? nowEpochSeconds + 3600, - 'iat': iatEpochSeconds ?? nowEpochSeconds, - 'aud': ?aud, - 'client_id': ?clientId, - 'iss': ?iss, - 'scope': scope ?? (clientId == null ? 'atproto' : 'atproto transition:generic'), - }); - - return '$header.$payload.signature'; -} - -atcore.UnauthorizedException testUnauthorizedException( - String methodId, { - atcore.HttpMethod method = atcore.HttpMethod.get, -}) => atcore.UnauthorizedException( - atcore.XRPCResponse( - headers: const {}, - status: atcore.HttpStatus.unauthorized, - request: atcore.XRPCRequest(method: method, url: Uri.https('bsky.social', '/xrpc/$methodId')), - rateLimit: atcore.RateLimit.unlimited(), - data: const atcore.XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), - ), -); diff --git a/test/core/app/app_version_test.dart b/test/core/app/app_version_test.dart --- a/test/core/app/app_version_test.dart +++ b/test/core/app/app_version_test.dart @@ -1,106 +1,56 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/app/app_version.dart'; -import 'package:package_info_plus/package_info_plus.dart'; + +import '../../helpers/fixtures/package_info.dart'; void main() { group('AppVersion', () { test('shows current prerelease label for platform-safe numeric versions', () { - final label = AppVersion.displayLabelFor( - PackageInfo( - appName: 'Lazurite', - packageName: 'org.stormlightlabs.lazurite', - version: '1.0.0', - buildNumber: '6', - ), - ); + final label = AppVersion.displayLabelFor(testPackageInfo()); expect(label, equals('Lazurite v1.0.0 alpha 6')); }); test('uses app name from package metadata', () { final label = AppVersion.displayLabelFor( - PackageInfo( - appName: 'Lazurite Nightly', - packageName: 'org.stormlightlabs.lazurite.nightly', - version: '1.0.0', - buildNumber: '6', - ), + testPackageInfo(appName: 'Lazurite Nightly', packageName: 'org.stormlightlabs.lazurite.nightly'), ); expect(label, equals('Lazurite Nightly v1.0.0 alpha 6')); }); test('falls back to Lazurite when package app name is empty', () { - final label = AppVersion.displayLabelFor( - PackageInfo(appName: ' ', packageName: 'org.stormlightlabs.lazurite', version: '1.0.0', buildNumber: '6'), - ); + final label = AppVersion.displayLabelFor(testPackageInfo(appName: ' ')); expect(label, equals('Lazurite v1.0.0 alpha 6')); }); test('shows prerelease channel and build number together', () { - final label = AppVersion.displayLabelFor( - PackageInfo( - appName: 'Lazurite', - packageName: 'org.stormlightlabs.lazurite', - version: '1.0.0-alpha.6', - buildNumber: '6', - ), - ); + final label = AppVersion.displayLabelFor(testPackageInfo(version: '1.0.0-alpha.6')); expect(label, equals('Lazurite v1.0.0 alpha 6')); }); test('uses build number as prerelease number when version has only the channel', () { - final label = AppVersion.displayLabelFor( - PackageInfo( - appName: 'Lazurite', - packageName: 'org.stormlightlabs.lazurite', - version: '1.0.0-alpha', - buildNumber: '6', - ), - ); + final label = AppVersion.displayLabelFor(testPackageInfo(version: '1.0.0-alpha')); expect(label, equals('Lazurite v1.0.0 alpha 6')); }); test('shows native build separately when prerelease number differs', () { - final label = AppVersion.displayLabelFor( - PackageInfo( - appName: 'Lazurite', - packageName: 'org.stormlightlabs.lazurite', - version: '1.0.0-alpha.6', - buildNumber: '42', - ), - ); + final label = AppVersion.displayLabelFor(testPackageInfo(version: '1.0.0-alpha.6', buildNumber: '42')); expect(label, equals('Lazurite v1.0.0 alpha 6 (build 42)')); }); test('shows build number for stable versions', () { - final label = AppVersion.displayLabelFor( - PackageInfo( - appName: 'Lazurite', - packageName: 'org.stormlightlabs.lazurite', - version: '1.0.0', - buildNumber: '42', - ), - prereleaseLabel: null, - ); + final label = AppVersion.displayLabelFor(testPackageInfo(buildNumber: '42'), prereleaseLabel: null); expect(label, equals('Lazurite v1.0.0 (build 42)')); }); test('omits duplicate iOS build number fallback', () { - final label = AppVersion.displayLabelFor( - PackageInfo( - appName: 'Lazurite', - packageName: 'org.stormlightlabs.lazurite', - version: '1.0.0', - buildNumber: '1.0.0', - ), - prereleaseLabel: null, - ); + final label = AppVersion.displayLabelFor(testPackageInfo(buildNumber: '1.0.0'), prereleaseLabel: null); expect(label, equals('Lazurite v1.0.0')); }); diff --git a/test/core/cache/poptart_cache_codecs_test.dart b/test/core/cache/poptart_cache_codecs_test.dart --- a/test/core/cache/poptart_cache_codecs_test.dart +++ b/test/core/cache/poptart_cache_codecs_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; -import '../../helpers/feed_fixtures.dart'; +import '../../helpers/fixtures/feed.dart'; void main() { group('PoptartCacheCodecs', () { diff --git a/test/core/network/actor_repository_service_resolver_test.dart b/test/core/network/actor_repository_service_resolver_test.dart --- a/test/core/network/actor_repository_service_resolver_test.dart +++ b/test/core/network/actor_repository_service_resolver_test.dart @@ -5,6 +5,8 @@ import 'package:http/testing.dart'; import 'package:lazurite/core/network/actor_repository_service_resolver.dart'; +import '../../helpers/fixtures/network.dart'; + void main() { group('ActorRepositoryServiceResolver', () { test('resolves handle through public identity host and then DID doc', () async { @@ -18,15 +20,7 @@ } if (request.url.host == 'plc.directory' && request.url.path == '/did:plc:alice') { return http.Response( - jsonEncode({ - 'service': [ - { - 'id': '#atproto_pds', - 'type': 'AtprotoPersonalDataServer', - 'serviceEndpoint': 'https://alice.us-east.host.bsky.network', - }, - ], - }), + jsonEncode(testDidDocument(serviceEndpoint: 'https://alice.us-east.host.bsky.network')), 200, ); } @@ -50,18 +44,7 @@ httpClient: MockClient((request) async { requestedUris.add(request.url); if (request.url.host == 'example.com' && request.url.path == '/users/alice/did.json') { - return http.Response( - jsonEncode({ - 'service': [ - { - 'id': '#atproto_pds', - 'type': 'AtprotoPersonalDataServer', - 'serviceEndpoint': 'https://pds.example.com', - }, - ], - }), - 200, - ); + return http.Response(jsonEncode(testDidDocument(serviceEndpoint: 'https://pds.example.com')), 200); } return http.Response('not found', 404); }), @@ -89,18 +72,7 @@ return http.Response(jsonEncode({'did': 'did:plc:fallback'}), 200); } if (request.url.host == 'plc.directory' && request.url.path == '/did:plc:fallback') { - return http.Response( - jsonEncode({ - 'service': [ - { - 'id': '#atproto_pds', - 'type': 'AtprotoPersonalDataServer', - 'serviceEndpoint': 'https://fallback.host', - }, - ], - }), - 200, - ); + return http.Response(jsonEncode(testDidDocument(serviceEndpoint: 'https://fallback.host')), 200); } return http.Response('not found', 404); }), @@ -123,14 +95,7 @@ return http.Response(jsonEncode({'did': 'did:plc:cache'}), 200); } if (request.url.host == 'plc.directory') { - return http.Response( - jsonEncode({ - 'service': [ - {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://cache.host'}, - ], - }), - 200, - ); + return http.Response(jsonEncode(testDidDocument(serviceEndpoint: 'https://cache.host')), 200); } return http.Response('not found', 404); }), diff --git a/test/core/network/atproto_host_resolver_test.dart b/test/core/network/atproto_host_resolver_test.dart --- a/test/core/network/atproto_host_resolver_test.dart +++ b/test/core/network/atproto_host_resolver_test.dart @@ -1,40 +1,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; -import 'package:lazurite/features/auth/data/models/auth_models.dart'; - -import '../../helpers/test_utils.dart'; +import '../../helpers/fixtures/auth.dart'; void main() { group('resolvePdsHost', () { test('uses stored PDS endpoint when restoring an opaque OAuth token', () { - final tokens = testAuthTokens( - accessToken: 'opaque-access-token', - refreshToken: 'refresh-token', + final tokens = testOpaqueOAuthTokens( expiresAt: DateTime.utc(2030), - did: 'did:plc:alice', - handle: 'alice.bsky.social', service: 'https://porcini.us-east.host.bsky.network', oauthClientId: 'https://client.example/metadata.json', - oauthTokenType: 'DPoP', - oauthScope: 'atproto transition:generic', dpopPublicKey: 'public-key', dpopPrivateKey: 'private-key', - authMethod: AuthMethod.oauth, ); expect(resolvePdsHost(tokens), 'porcini.us-east.host.bsky.network'); }); test('falls back to stored service when opaque OAuth metadata is incomplete', () { - final tokens = testAuthTokens( - accessToken: 'opaque-access-token', - refreshToken: 'refresh-token', - did: 'did:plc:alice', - handle: 'alice.bsky.social', + final tokens = testOpaqueOAuthTokens( service: 'porcini.us-east.host.bsky.network', dpopPublicKey: 'public-key', dpopPrivateKey: 'private-key', - authMethod: AuthMethod.oauth, ); expect(resolvePdsHost(tokens), 'porcini.us-east.host.bsky.network'); diff --git a/test/core/network/oauth_session_restorer_test.dart b/test/core/network/oauth_session_restorer_test.dart --- a/test/core/network/oauth_session_restorer_test.dart +++ b/test/core/network/oauth_session_restorer_test.dart @@ -3,7 +3,7 @@ import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:poptart_core/poptart_core.dart'; -import '../../helpers/test_utils.dart'; +import '../../helpers/fixtures/auth.dart'; void main() { group('restoreOAuthSessionFromTokens', () { diff --git a/test/core/network/unauthorized_recovery_runner_test.dart b/test/core/network/unauthorized_recovery_runner_test.dart --- a/test/core/network/unauthorized_recovery_runner_test.dart +++ b/test/core/network/unauthorized_recovery_runner_test.dart @@ -2,7 +2,8 @@ import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; import 'package:poptart_core/poptart_core.dart' show UnauthorizedException; -import '../../helpers/test_utils.dart'; +import '../../helpers/fixtures/auth.dart'; +import '../../helpers/fixtures/network.dart'; void main() { group('UnauthorizedRecoveryRunner', () { diff --git a/test/core/network/xrpc_client_factory_test.dart b/test/core/network/xrpc_client_factory_test.dart --- a/test/core/network/xrpc_client_factory_test.dart +++ b/test/core/network/xrpc_client_factory_test.dart @@ -3,7 +3,7 @@ import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:poptart_core/poptart_core.dart' as atp_core; -import '../../helpers/test_utils.dart'; +import '../../helpers/fixtures/auth.dart'; void main() { group('xrpc_client_factory', () { diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -14,7 +14,6 @@ import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; -import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; @@ -36,6 +35,10 @@ import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:poptart_core/poptart_core.dart' as atcore; + +import '../../helpers/fixtures/feed.dart'; +import '../../helpers/fixtures/auth.dart'; +import '../../helpers/connectivity_helpers.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} @@ -128,13 +131,7 @@ late StreamController authController; late AuthState currentAuthState; - const tokens = AuthTokens( - accessToken: 'access', - refreshToken: 'refresh', - did: 'did:plc:me', - handle: 'me.bsky.social', - displayName: 'River Tam', - ); + final tokens = testRiverTokens(); final profile = ProfileViewDetailed( did: 'did:plc:me', @@ -170,7 +167,7 @@ typeaheadRepository = MockTypeaheadRepository(); database = MockAppDatabase(); authController = StreamController.broadcast(); - currentAuthState = const AuthState.authenticated(tokens); + currentAuthState = AuthState.authenticated(tokens); when(() => authBloc.state).thenAnswer((_) => currentAuthState); when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((_) async => false); @@ -187,7 +184,7 @@ ), ); when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); - when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + stubConnectivityCubit(connectivityCubit); when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); when(() => accountSwitcherCubit.loadAccounts()).thenAnswer((_) async {}); when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); @@ -568,7 +565,7 @@ }); testWidgets('authenticated root remains on the home feed', (tester) async { - currentAuthState = const AuthState.authenticated(tokens); + currentAuthState = AuthState.authenticated(tokens); when(() => authBloc.state).thenReturn(currentAuthState); whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); @@ -1320,7 +1317,7 @@ }); testWidgets('authenticated settings back button falls back to home when there is no stack to pop', (tester) async { - currentAuthState = const AuthState.authenticated(tokens); + currentAuthState = AuthState.authenticated(tokens); when(() => authBloc.state).thenReturn(currentAuthState); whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); @@ -1335,15 +1332,13 @@ await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); - expect(find.text('HOME'), findsAtLeastNWidgets(1)); expect(find.text('APPEARANCE'), findsNothing); - router.dispose(); }); testWidgets('authenticated settings back button returns to profile when opened from profile', (tester) async { - currentAuthState = const AuthState.authenticated(tokens); + currentAuthState = AuthState.authenticated(tokens); when(() => authBloc.state).thenReturn(currentAuthState); whenListen(authBloc, Stream.value(currentAuthState), initialState: currentAuthState); @@ -1361,10 +1356,8 @@ await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); - expect(find.text('RIVER TAM'), findsOneWidget); expect(find.text('APPEARANCE'), findsNothing); - router.dispose(); }); @@ -1391,7 +1384,6 @@ await tester.pump(const Duration(milliseconds: 500)); expect(find.text('No feeds pinned'), findsOneWidget); - router.dispose(); }); @@ -1407,7 +1399,6 @@ expect(find.byType(CupertinoPageTransition), findsWidgets); expect(router.canPop(), isTrue); - router.dispose(); }); @@ -1433,7 +1424,6 @@ await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('login-continue-button')), findsOneWidget); - router.dispose(); }); @@ -1448,7 +1438,6 @@ expect(find.byKey(const ValueKey('login-continue-button')), findsOneWidget); expect(find.byKey(const ValueKey('unauthenticated-navigation-bar')), findsNothing); - router.dispose(); }); @@ -1490,7 +1479,6 @@ expect(router.routeInformationProvider.value.uri.path, isNot(equals(OAuthCallbackScreen.routePath))); expect(find.text('No feeds pinned'), findsOneWidget); - router.dispose(); }); @@ -1524,7 +1512,6 @@ expect(router.routeInformationProvider.value.uri.path, isNot(equals(OAuthCallbackScreen.routePath))); expect(find.text('No feeds pinned'), findsOneWidget); - router.dispose(); }); @@ -1555,22 +1542,14 @@ }); } -FeedViewPost _publicFeedPost() { - final record = FeedPostRecord(text: 'Public route post', createdAt: DateTime.utc(2026, 5, 18)); - return FeedViewPost( - post: PostView( - uri: atcore.AtUri.parse('at://did:plc:author/app.bsky.feed.post/route'), - cid: 'cid-route', - author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), - record: record.toJson(), - indexedAt: DateTime.utc(2026, 5, 18), - replyCount: 1, - repostCount: 2, - likeCount: 3, - ), - ); -} +FeedViewPost _publicFeedPost() => testFeedViewPost( + uri: 'at://did:plc:author/app.bsky.feed.post/route', + cid: 'cid-route', + record: FeedPostRecord(text: 'Public route post', createdAt: DateTime.utc(2026, 5, 18)).toJson(), + indexedAt: DateTime.utc(2026, 5, 18), + replyCount: 1, + repostCount: 2, + likeCount: 3, +); -ThreadViewPost _publicThread() { - return ThreadViewPost(post: _publicFeedPost().post); -} +ThreadViewPost _publicThread() => ThreadViewPost(post: _publicFeedPost().post); diff --git a/test/core/widgets/lazurite_app_bar_test.dart b/test/core/widgets/lazurite_app_bar_test.dart --- a/test/core/widgets/lazurite_app_bar_test.dart +++ b/test/core/widgets/lazurite_app_bar_test.dart @@ -10,8 +10,9 @@ import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:mocktail/mocktail.dart'; -import '../../helpers/settings_fixtures.dart'; -import '../../helpers/test_utils.dart'; +import '../../helpers/fixtures/settings.dart'; +import '../../helpers/fixtures/auth.dart'; +import '../../helpers/connectivity_helpers.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} @@ -24,11 +25,7 @@ late MockConnectivityCubit connectivityCubit; late MockSettingsCubit settingsCubit; - final tokens = testAuthTokens( - accessToken: 'access', - refreshToken: 'refresh', - displayName: 'River Tam', - ); + final tokens = testAuthTokens(accessToken: 'access', refreshToken: 'refresh', displayName: 'River Tam'); setUp(() { authBloc = MockAuthBloc(); @@ -36,12 +33,7 @@ settingsCubit = MockSettingsCubit(); when(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); whenListen(authBloc, const Stream.empty(), initialState: AuthState.authenticated(tokens)); - when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); - whenListen( - connectivityCubit, - const Stream.empty(), - initialState: const ConnectivityState.online(), - ); + stubConnectivityCubit(connectivityCubit, state: const ConnectivityState.online()); when(() => settingsCubit.state).thenReturn(testSettingsState()); whenListen(settingsCubit, const Stream.empty(), initialState: testSettingsState()); when(() => settingsCubit.setSimulateOffline(any())).thenAnswer((_) async {}); @@ -88,12 +80,7 @@ }); testWidgets('shows simulated offline indicator and lets the user disable it', (tester) async { - when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online(isSimulatedOffline: true)); - whenListen( - connectivityCubit, - const Stream.empty(), - initialState: const ConnectivityState.online(isSimulatedOffline: true), - ); + stubConnectivityCubit(connectivityCubit, state: const ConnectivityState.online(isSimulatedOffline: true)); await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); await tester.pumpAndSettle(); diff --git a/test/helpers/fixtures/auth.dart b/test/helpers/fixtures/auth.dart new file mode 100644 --- /dev/null +++ b/test/helpers/fixtures/auth.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; + +import 'package:lazurite/features/auth/data/models/auth_models.dart'; + +String base64UrlEncode(Map value) => + base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); + +String buildJwt({ + required String sub, + String? aud, + String? clientId, + String? iss, + String? scope, + int? expEpochSeconds, + int? iatEpochSeconds, +}) { + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final header = base64UrlEncode(const {'alg': 'none', 'typ': 'JWT'}); + final payload = base64UrlEncode({ + 'sub': sub, + 'exp': expEpochSeconds ?? nowEpochSeconds + 3600, + 'iat': iatEpochSeconds ?? nowEpochSeconds, + 'aud': ?aud, + 'client_id': ?clientId, + 'iss': ?iss, + 'scope': scope ?? (clientId == null ? 'atproto' : 'atproto transition:generic'), + }); + + return '$header.$payload.signature'; +} + +const Object _expiresAtDefault = Object(); + +AuthTokens testAliceTokens({ + String accessToken = 'opaque-access-token', + String? refreshToken = 'refresh-token', + DateTime? expiresAt, +}) => testAuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + did: 'did:plc:alice', + handle: 'alice.bsky.social', + displayName: 'Alice', + expiresAt: expiresAt, +); + +AuthTokens testRiverTokens({String accessToken = 'access', String? refreshToken = 'refresh', DateTime? expiresAt}) => + testAuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + did: 'did:plc:me', + handle: 'me.bsky.social', + displayName: 'River Tam', + expiresAt: expiresAt, + ); + +AuthTokens testOAuthTokens({ + String accessToken = 'oauth-access-token', + String? refreshToken = 'oauth-refresh-token', + String did = 'did:plc:alice', + String handle = 'alice.bsky.social', + String? displayName = 'Alice', + String service = 'https://pds.example.com', + String oauthService = 'https://bsky.social', + String oauthClientId = 'client-id', + String oauthTokenType = 'DPoP', + String oauthScope = 'atproto transition:generic', + String? dpopNonce, + String? dpopPublicKey, + String? dpopPrivateKey, + DateTime? expiresAt, +}) => testAuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + did: did, + handle: handle, + displayName: displayName, + service: service, + oauthService: oauthService, + oauthClientId: oauthClientId, + oauthTokenType: oauthTokenType, + oauthScope: oauthScope, + dpopNonce: dpopNonce, + dpopPublicKey: dpopPublicKey, + dpopPrivateKey: dpopPrivateKey, + authMethod: AuthMethod.oauth, + expiresAt: expiresAt, +); + +AuthTokens testOpaqueOAuthTokens({ + String accessToken = 'opaque-access-token', + String? refreshToken = 'refresh-token', + String did = 'did:plc:alice', + String handle = 'alice.bsky.social', + String? displayName = 'Alice', + String service = 'https://pds.example.com', + String oauthClientId = 'client-id', + String oauthTokenType = 'DPoP', + String oauthScope = 'atproto transition:generic', + String? dpopNonce, + String? dpopPublicKey, + String? dpopPrivateKey, + DateTime? expiresAt, +}) => testOAuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + did: did, + handle: handle, + displayName: displayName, + service: service, + oauthClientId: oauthClientId, + oauthTokenType: oauthTokenType, + oauthScope: oauthScope, + dpopNonce: dpopNonce, + dpopPublicKey: dpopPublicKey, + dpopPrivateKey: dpopPrivateKey, + expiresAt: expiresAt, +); + +AuthTokens testPdsOAuthTokens({ + String accessToken = 'access', + String? refreshToken = 'refresh', + String service = 'https://pds.example.com', + DateTime? expiresAt, +}) => testOAuthTokens(accessToken: accessToken, refreshToken: refreshToken, service: service, expiresAt: expiresAt); + +AuthTokens testAuthTokens({ + String accessToken = 'access-token', + String? refreshToken = 'refresh-token', + String did = 'did:plc:test', + String handle = 'test.bsky.social', + String? displayName, + String? service = 'bsky.social', + Object? expiresAt = _expiresAtDefault, + String? oauthService, + String? oauthClientId, + String? oauthTokenType, + String? oauthScope, + String? dpopNonce, + String? dpopPublicKey, + String? dpopPrivateKey, + AuthMethod authMethod = AuthMethod.appPassword, +}) => AuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: identical(expiresAt, _expiresAtDefault) + ? DateTime.now().toUtc().add(const Duration(hours: 1)) + : expiresAt as DateTime?, + did: did, + handle: handle, + displayName: displayName, + service: service, + oauthService: oauthService, + oauthClientId: oauthClientId, + oauthTokenType: oauthTokenType, + oauthScope: oauthScope, + dpopNonce: dpopNonce, + dpopPublicKey: dpopPublicKey, + dpopPrivateKey: dpopPrivateKey, + authMethod: authMethod, +); diff --git a/test/helpers/fixtures/feed.dart b/test/helpers/fixtures/feed.dart new file mode 100644 --- /dev/null +++ b/test/helpers/fixtures/feed.dart @@ -0,0 +1,84 @@ +import 'package:bluesky_poptart/app/bsky/actor/defs.dart' hide ViewerState; +import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; +import 'package:poptart_core/poptart_core.dart'; +import 'package:poptart_lex/com/atproto/label/defs.dart'; + +const testAuthorDid = 'did:plc:author'; +const testAuthorHandle = 'author.bsky.social'; +const testPostUri = 'at://did:plc:author/app.bsky.feed.post/abc'; + +ProfileViewBasic testProfileViewBasic({ + String did = testAuthorDid, + String handle = testAuthorHandle, + String? displayName, + String? avatar, +}) => ProfileViewBasic(did: did, handle: handle, displayName: displayName, avatar: avatar); + +Map testPostRecordJson({ + String text = 'Test post', + DateTime? createdAt, + Map extra = const {}, +}) => { + r'$type': 'app.bsky.feed.post', + 'text': text, + 'createdAt': (createdAt ?? DateTime.utc(2026, 3, 15)).toUtc().toIso8601String(), + ...extra, +}; + +PostView testPostView({ + String uri = testPostUri, + String? cid, + ProfileViewBasic? author, + Map? record, + DateTime? indexedAt, + int? replyCount, + int? repostCount, + int? likeCount, + int? quoteCount, + UPostViewEmbed? embed, + List