mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: video upload limits & suggested follows data layer

+553 -37
+6 -6
docs/tasks/phase-5.md
··· 33 33 34 34 ### Core 35 35 36 - - [ ] `ProfileRepository.getSuggestedFollows()` - call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>` 36 + - [x] `ProfileRepository.getSuggestedFollows()` - call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>` 37 37 38 38 ### Cubit 39 39 40 - - [ ] `SuggestedFollowsCubit` - `load(actor:)` fetches suggestions, exposes loaded/loading/error states 40 + - [x] `SuggestedFollowsCubit` - `load(actor:)` fetches suggestions, exposes loaded/loading/error states 41 41 42 42 ### UI 43 43 ··· 49 49 50 50 ### Tests 51 51 52 - - [ ] Unit tests: repository method, cubit state transitions 52 + - [x] Unit tests: repository method, cubit state transitions 53 53 - [ ] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state 54 54 55 55 ## M22 - Video Upload Limits 56 56 57 57 ### Core 58 58 59 - - [ ] `VideoRepository` (or extend settings repository) - `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result 59 + - [x] `VideoRepository` (or extend settings repository) - `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result 60 60 61 61 ### Cubit 62 62 63 - - [ ] `VideoUploadLimitsCubit` - fetch on init, expose `canUpload`, remaining counts, message/error 63 + - [x] `VideoUploadLimitsCubit` - fetch on init, expose `canUpload`, remaining counts, message/error 64 64 65 65 ### UI 66 66 ··· 71 71 72 72 ### Tests 73 73 74 - - [ ] Unit tests: repository method, cubit state transitions and formatting 74 + - [x] Unit tests: repository method, cubit state transitions and formatting 75 75 - [ ] Widget tests: tile renders limits, loading indicator, error state, message display 76 76 77 77 ## M23 - Profile Context (Constellation)
+27
lib/features/profile/cubit/suggested_follows_cubit.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 5 + 6 + part 'suggested_follows_state.dart'; 7 + 8 + class SuggestedFollowsCubit extends Cubit<SuggestedFollowsState> { 9 + SuggestedFollowsCubit({required ProfileRepository repository}) 10 + : _repository = repository, 11 + super(const SuggestedFollowsState.initial()); 12 + 13 + final ProfileRepository _repository; 14 + 15 + Future<void> load(String actor) async { 16 + if (state.isLoading) return; 17 + 18 + emit(const SuggestedFollowsState.loading()); 19 + 20 + try { 21 + final suggestions = await _repository.getSuggestedFollows(actor); 22 + emit(SuggestedFollowsState.loaded(suggestions)); 23 + } catch (error) { 24 + emit(SuggestedFollowsState.error('Failed to load suggestions: $error')); 25 + } 26 + } 27 + }
+28
lib/features/profile/cubit/suggested_follows_state.dart
··· 1 + part of 'suggested_follows_cubit.dart'; 2 + 3 + enum SuggestedFollowsStatus { initial, loading, loaded, error } 4 + 5 + class SuggestedFollowsState extends Equatable { 6 + const SuggestedFollowsState._({required this.status, this.suggestions = const [], this.errorMessage}); 7 + 8 + const SuggestedFollowsState.initial() : this._(status: SuggestedFollowsStatus.initial); 9 + 10 + const SuggestedFollowsState.loading() : this._(status: SuggestedFollowsStatus.loading); 11 + 12 + const SuggestedFollowsState.loaded(List<ProfileView> suggestions) 13 + : this._(status: SuggestedFollowsStatus.loaded, suggestions: suggestions); 14 + 15 + const SuggestedFollowsState.error(String message) 16 + : this._(status: SuggestedFollowsStatus.error, errorMessage: message); 17 + 18 + final SuggestedFollowsStatus status; 19 + final List<ProfileView> suggestions; 20 + final String? errorMessage; 21 + 22 + bool get isLoading => status == SuggestedFollowsStatus.loading; 23 + bool get hasError => status == SuggestedFollowsStatus.error; 24 + bool get isEmpty => status == SuggestedFollowsStatus.loaded && suggestions.isEmpty; 25 + 26 + @override 27 + List<Object?> get props => [status, suggestions, errorMessage]; 28 + }
+8
lib/features/profile/data/profile_repository.dart
··· 65 65 return profiles; 66 66 } 67 67 68 + Future<List<ProfileView>> getSuggestedFollows(String actor) async { 69 + final response = await _bluesky.graph.getSuggestedFollowsByActor(actor: actor); 70 + final suggestions = response.data.suggestions; 71 + final moderationService = _moderationService; 72 + if (moderationService == null) return suggestions; 73 + return suggestions.where((p) => !moderationService.shouldFilterProfileInList(p)).toList(); 74 + } 75 + 68 76 Future<ProfileViewDetailed?> getCurrentUserProfile(AuthTokens tokens) async { 69 77 log.d('ProfileRepository: Loading current user profile for ${tokens.did} via ${_describeClientContext()}'); 70 78
-29
lib/features/profile/presentation/profile_context_screen.dart
··· 20 20 class _ProfileContextScreenState extends State<ProfileContextScreen> with SingleTickerProviderStateMixin { 21 21 late final TabController _tabController; 22 22 23 - // Track whether each tab has had its initial load triggered. 24 23 bool _blockingLoaded = false; 25 24 bool _listsOnLoaded = false; 26 25 ··· 29 28 super.initState(); 30 29 _tabController = TabController(length: 3, vsync: this); 31 30 _tabController.addListener(_onTabChanged); 32 - // Kick off the counts fetch immediately. 33 31 context.read<ProfileContextCubit>().init(); 34 32 } 35 33 ··· 99 97 } 100 98 } 101 99 102 - // --------------------------------------------------------------------------- 103 - // Blocked By tab 104 - // --------------------------------------------------------------------------- 105 - 106 100 class _BlockedByTab extends StatelessWidget { 107 101 const _BlockedByTab({required this.state}); 108 102 ··· 125 119 child: CustomScrollView( 126 120 physics: const AlwaysScrollableScrollPhysics(), 127 121 slivers: [ 128 - // Contextualizing note at the top. 129 122 const SliverToBoxAdapter( 130 123 child: Padding( 131 124 padding: EdgeInsets.fromLTRB(16, 16, 16, 8), ··· 136 129 ), 137 130 ), 138 131 ), 139 - // Count header + expand button. 140 132 SliverToBoxAdapter( 141 133 child: Padding( 142 134 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ··· 158 150 ), 159 151 ), 160 152 ), 161 - // Content based on status. 162 153 if (state.blockedByStatus == ProfileContextTabStatus.loading && state.blockedByEntries.isEmpty) 163 154 const SliverFillRemaining(hasScrollBody: false, child: Center(child: _ShimmerList())) 164 155 else if (state.blockedByStatus == ProfileContextTabStatus.error && state.blockedByEntries.isEmpty) ··· 229 220 ); 230 221 } 231 222 } 232 - 233 - // --------------------------------------------------------------------------- 234 - // Blocking tab 235 - // --------------------------------------------------------------------------- 236 223 237 224 class _BlockingTab extends StatelessWidget { 238 225 const _BlockingTab({required this.state}); ··· 347 334 } 348 335 } 349 336 350 - // --------------------------------------------------------------------------- 351 - // Lists On tab 352 - // --------------------------------------------------------------------------- 353 - 354 337 class _ListsOnTab extends StatelessWidget { 355 338 const _ListsOnTab({required this.state}); 356 339 ··· 418 401 ); 419 402 } 420 403 } 421 - 422 - // --------------------------------------------------------------------------- 423 - // Reusable profile tile 424 - // --------------------------------------------------------------------------- 425 404 426 405 class _ProfileTile extends StatelessWidget { 427 406 const _ProfileTile({super.key, required this.profile, this.onTap}); ··· 716 695 } 717 696 } 718 697 719 - // --------------------------------------------------------------------------- 720 - // Shimmer skeleton 721 - // --------------------------------------------------------------------------- 722 - 723 698 class _ShimmerList extends StatefulWidget { 724 699 const _ShimmerList(); 725 700 ··· 806 781 ); 807 782 } 808 783 } 809 - 810 - // --------------------------------------------------------------------------- 811 - // Error + retry widget 812 - // --------------------------------------------------------------------------- 813 784 814 785 class _ErrorRetry extends StatelessWidget { 815 786 const _ErrorRetry({required this.message, required this.onRetry});
+26
lib/features/settings/cubit/video_upload_limits_cubit.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/features/settings/data/video_repository.dart'; 4 + 5 + part 'video_upload_limits_state.dart'; 6 + 7 + class VideoUploadLimitsCubit extends Cubit<VideoUploadLimitsState> { 8 + VideoUploadLimitsCubit({required VideoRepository repository}) 9 + : _repository = repository, 10 + super(const VideoUploadLimitsState.initial()); 11 + 12 + final VideoRepository _repository; 13 + 14 + Future<void> fetch() async { 15 + if (state.isLoading) return; 16 + 17 + emit(const VideoUploadLimitsState.loading()); 18 + 19 + try { 20 + final limits = await _repository.getUploadLimits(); 21 + emit(VideoUploadLimitsState.loaded(limits)); 22 + } catch (error) { 23 + emit(VideoUploadLimitsState.error('Failed to load upload limits: $error')); 24 + } 25 + } 26 + }
+27
lib/features/settings/cubit/video_upload_limits_state.dart
··· 1 + part of 'video_upload_limits_cubit.dart'; 2 + 3 + enum VideoUploadLimitsStatus { initial, loading, loaded, error } 4 + 5 + class VideoUploadLimitsState extends Equatable { 6 + const VideoUploadLimitsState._({required this.status, this.limits, this.errorMessage}); 7 + 8 + const VideoUploadLimitsState.initial() : this._(status: VideoUploadLimitsStatus.initial); 9 + 10 + const VideoUploadLimitsState.loading() : this._(status: VideoUploadLimitsStatus.loading); 11 + 12 + const VideoUploadLimitsState.loaded(VideoUploadLimits limits) 13 + : this._(status: VideoUploadLimitsStatus.loaded, limits: limits); 14 + 15 + const VideoUploadLimitsState.error(String message) 16 + : this._(status: VideoUploadLimitsStatus.error, errorMessage: message); 17 + 18 + final VideoUploadLimitsStatus status; 19 + final VideoUploadLimits? limits; 20 + final String? errorMessage; 21 + 22 + bool get isLoading => status == VideoUploadLimitsStatus.loading; 23 + bool get hasError => status == VideoUploadLimitsStatus.error; 24 + 25 + @override 26 + List<Object?> get props => [status, limits, errorMessage]; 27 + }
+35
lib/features/settings/data/video_repository.dart
··· 1 + import 'package:bluesky/bluesky.dart'; 2 + 3 + class VideoRepository { 4 + VideoRepository({required Bluesky bluesky}) : _bluesky = bluesky; 5 + 6 + final Bluesky _bluesky; 7 + 8 + Future<VideoUploadLimits> getUploadLimits() async { 9 + final response = await _bluesky.video.getUploadLimits(); 10 + final data = response.data; 11 + return VideoUploadLimits( 12 + canUpload: data.canUpload, 13 + remainingDailyVideos: data.remainingDailyVideos, 14 + remainingDailyBytes: data.remainingDailyBytes, 15 + message: data.message, 16 + error: data.error, 17 + ); 18 + } 19 + } 20 + 21 + class VideoUploadLimits { 22 + const VideoUploadLimits({ 23 + required this.canUpload, 24 + this.remainingDailyVideos, 25 + this.remainingDailyBytes, 26 + this.message, 27 + this.error, 28 + }); 29 + 30 + final bool canUpload; 31 + final int? remainingDailyVideos; 32 + final int? remainingDailyBytes; 33 + final String? message; 34 + final String? error; 35 + }
+110
test/features/profile/cubit/suggested_follows_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; 5 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockProfileRepository extends Mock implements ProfileRepository {} 9 + 10 + const _actor = 'did:plc:alice'; 11 + 12 + ProfileView _profile(String did) => ProfileView(did: did, handle: '$did.bsky.social', indexedAt: DateTime.utc(2026)); 13 + 14 + void main() { 15 + late MockProfileRepository mockRepository; 16 + 17 + setUp(() { 18 + mockRepository = MockProfileRepository(); 19 + }); 20 + 21 + SuggestedFollowsCubit buildCubit() => SuggestedFollowsCubit(repository: mockRepository); 22 + 23 + group('SuggestedFollowsCubit', () { 24 + group('initial state', () { 25 + test('has initial status and empty suggestions', () { 26 + final cubit = buildCubit(); 27 + expect(cubit.state.status, SuggestedFollowsStatus.initial); 28 + expect(cubit.state.suggestions, isEmpty); 29 + expect(cubit.state.errorMessage, isNull); 30 + expect(cubit.state.isLoading, isFalse); 31 + expect(cubit.state.hasError, isFalse); 32 + expect(cubit.state.isEmpty, isFalse); 33 + }); 34 + }); 35 + 36 + group('load', () { 37 + blocTest<SuggestedFollowsCubit, SuggestedFollowsState>( 38 + 'emits loading then loaded with suggestions', 39 + build: buildCubit, 40 + setUp: () { 41 + when( 42 + () => mockRepository.getSuggestedFollows(_actor), 43 + ).thenAnswer((_) async => [_profile('did:plc:bob'), _profile('did:plc:carol')]); 44 + }, 45 + act: (cubit) => cubit.load(_actor), 46 + expect: () => [ 47 + const SuggestedFollowsState.loading(), 48 + predicate<SuggestedFollowsState>( 49 + (s) => s.status == SuggestedFollowsStatus.loaded && s.suggestions.length == 2, 50 + ), 51 + ], 52 + ); 53 + 54 + blocTest<SuggestedFollowsCubit, SuggestedFollowsState>( 55 + 'emits loaded with empty list when no suggestions returned', 56 + build: buildCubit, 57 + setUp: () { 58 + when(() => mockRepository.getSuggestedFollows(_actor)).thenAnswer((_) async => []); 59 + }, 60 + act: (cubit) => cubit.load(_actor), 61 + expect: () => [ 62 + const SuggestedFollowsState.loading(), 63 + predicate<SuggestedFollowsState>( 64 + (s) => s.status == SuggestedFollowsStatus.loaded && s.suggestions.isEmpty && s.isEmpty, 65 + ), 66 + ], 67 + ); 68 + 69 + blocTest<SuggestedFollowsCubit, SuggestedFollowsState>( 70 + 'emits error when repository throws', 71 + build: buildCubit, 72 + setUp: () { 73 + when(() => mockRepository.getSuggestedFollows(_actor)).thenThrow(Exception('network error')); 74 + }, 75 + act: (cubit) => cubit.load(_actor), 76 + expect: () => [ 77 + const SuggestedFollowsState.loading(), 78 + predicate<SuggestedFollowsState>((s) => s.status == SuggestedFollowsStatus.error && s.errorMessage != null), 79 + ], 80 + ); 81 + 82 + blocTest<SuggestedFollowsCubit, SuggestedFollowsState>( 83 + 'is a no-op when already loading', 84 + build: buildCubit, 85 + seed: () => const SuggestedFollowsState.loading(), 86 + setUp: () { 87 + when(() => mockRepository.getSuggestedFollows(any())).thenAnswer((_) async => []); 88 + }, 89 + act: (cubit) => cubit.load(_actor), 90 + expect: () => [], 91 + verify: (_) { 92 + verifyNever(() => mockRepository.getSuggestedFollows(any())); 93 + }, 94 + ); 95 + 96 + blocTest<SuggestedFollowsCubit, SuggestedFollowsState>( 97 + 'loaded state isEmpty is false when suggestions present', 98 + build: buildCubit, 99 + setUp: () { 100 + when(() => mockRepository.getSuggestedFollows(_actor)).thenAnswer((_) async => [_profile('did:plc:bob')]); 101 + }, 102 + act: (cubit) => cubit.load(_actor), 103 + expect: () => [ 104 + const SuggestedFollowsState.loading(), 105 + predicate<SuggestedFollowsState>((s) => s.status == SuggestedFollowsStatus.loaded && !s.isEmpty), 106 + ], 107 + ); 108 + }); 109 + }); 110 + }
+78 -1
test/features/profile/data/profile_repository_test.dart
··· 18 18 }); 19 19 20 20 group('ProfileRepository', () { 21 + group('getSuggestedFollows', () { 22 + test('returns suggestions from graph service', () async { 23 + final suggestions = [ 24 + ProfileView(did: 'did:plc:bob', handle: 'bob.bsky.social', indexedAt: DateTime.utc(2026)), 25 + ProfileView(did: 'did:plc:carol', handle: 'carol.bsky.social', indexedAt: DateTime.utc(2026)), 26 + ]; 27 + final repository = ProfileRepository( 28 + database: database, 29 + bluesky: _FakeBlueskyClient( 30 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 31 + graph: _FakeGraphService(suggestions: suggestions), 32 + ), 33 + ); 34 + 35 + final result = await repository.getSuggestedFollows('did:plc:alice'); 36 + 37 + expect(result.length, 2); 38 + expect(result.first.did, 'did:plc:bob'); 39 + }); 40 + 41 + test('returns empty list when no suggestions', () async { 42 + final repository = ProfileRepository( 43 + database: database, 44 + bluesky: _FakeBlueskyClient( 45 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 46 + graph: _FakeGraphService(suggestions: []), 47 + ), 48 + ); 49 + 50 + final result = await repository.getSuggestedFollows('did:plc:alice'); 51 + 52 + expect(result, isEmpty); 53 + }); 54 + 55 + test('propagates exceptions from graph service', () async { 56 + final repository = ProfileRepository( 57 + database: database, 58 + bluesky: _FakeBlueskyClient( 59 + actor: _FakeActorService(onGetProfile: (_) async => throw UnimplementedError()), 60 + graph: _FakeGraphService(onGetSuggested: (_) async => throw Exception('network error')), 61 + ), 62 + ); 63 + 64 + expect(() => repository.getSuggestedFollows('did:plc:alice'), throwsException); 65 + }); 66 + }); 67 + 21 68 test('loads and caches a profile after a successful xrpc response', () async { 22 69 final profile = _buildProfile(); 23 70 final repository = ProfileRepository( ··· 69 116 } 70 117 71 118 class _FakeBlueskyClient { 72 - _FakeBlueskyClient({required this.actor}); 119 + _FakeBlueskyClient({required this.actor, _FakeGraphService? graph}) : graph = graph ?? _FakeGraphService(); 73 120 74 121 final _FakeActorService actor; 122 + final _FakeGraphService graph; 75 123 } 76 124 77 125 class _FakeActorService { ··· 105 153 106 154 final List<ProfileView> profiles; 107 155 } 156 + 157 + class _FakeGraphService { 158 + _FakeGraphService({ 159 + List<ProfileView>? suggestions, 160 + Future<_FakeSuggestedResponse> Function(String actor)? onGetSuggested, 161 + }) : _suggestions = suggestions ?? [], 162 + _onGetSuggested = onGetSuggested; 163 + 164 + final List<ProfileView> _suggestions; 165 + final Future<_FakeSuggestedResponse> Function(String actor)? _onGetSuggested; 166 + 167 + Future<_FakeSuggestedResponse> getSuggestedFollowsByActor({required String actor}) { 168 + final handler = _onGetSuggested; 169 + if (handler != null) return handler(actor); 170 + return Future.value(_FakeSuggestedResponse(_FakeSuggestedData(_suggestions))); 171 + } 172 + } 173 + 174 + class _FakeSuggestedResponse { 175 + _FakeSuggestedResponse(this.data); 176 + 177 + final _FakeSuggestedData data; 178 + } 179 + 180 + class _FakeSuggestedData { 181 + const _FakeSuggestedData(this.suggestions); 182 + 183 + final List<ProfileView> suggestions; 184 + }
-1
test/features/profile/presentation/profile_context_screen_test.dart
··· 46 46 47 47 setUp(() { 48 48 cubit = MockProfileContextCubit(); 49 - // Stub async methods to return completed futures by default. 50 49 when(() => cubit.init()).thenAnswer((_) async {}); 51 50 when(() => cubit.loadBlockedBy()).thenAnswer((_) async {}); 52 51 when(() => cubit.loadBlockedBy(cursor: any(named: 'cursor'))).thenAnswer((_) async {});
+127
test/features/settings/cubit/video_upload_limits_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/settings/cubit/video_upload_limits_cubit.dart'; 4 + import 'package:lazurite/features/settings/data/video_repository.dart'; 5 + import 'package:mocktail/mocktail.dart'; 6 + 7 + class MockVideoRepository extends Mock implements VideoRepository {} 8 + 9 + const _canUploadLimits = VideoUploadLimits(canUpload: true, remainingDailyVideos: 5, remainingDailyBytes: 524288000); 10 + const _cannotUploadLimits = VideoUploadLimits( 11 + canUpload: false, 12 + remainingDailyVideos: 0, 13 + remainingDailyBytes: 0, 14 + error: 'DAILY_LIMIT_EXCEEDED', 15 + message: 'Daily limit reached', 16 + ); 17 + 18 + void main() { 19 + late MockVideoRepository mockRepository; 20 + 21 + setUp(() { 22 + mockRepository = MockVideoRepository(); 23 + }); 24 + 25 + VideoUploadLimitsCubit buildCubit() => VideoUploadLimitsCubit(repository: mockRepository); 26 + 27 + group('VideoUploadLimitsCubit', () { 28 + group('initial state', () { 29 + test('has initial status and null limits', () { 30 + final cubit = buildCubit(); 31 + expect(cubit.state.status, VideoUploadLimitsStatus.initial); 32 + expect(cubit.state.limits, isNull); 33 + expect(cubit.state.errorMessage, isNull); 34 + expect(cubit.state.isLoading, isFalse); 35 + expect(cubit.state.hasError, isFalse); 36 + }); 37 + }); 38 + 39 + group('fetch', () { 40 + blocTest<VideoUploadLimitsCubit, VideoUploadLimitsState>( 41 + 'emits loading then loaded with limits when canUpload is true', 42 + build: buildCubit, 43 + setUp: () { 44 + when(() => mockRepository.getUploadLimits()).thenAnswer((_) async => _canUploadLimits); 45 + }, 46 + act: (cubit) => cubit.fetch(), 47 + expect: () => [ 48 + const VideoUploadLimitsState.loading(), 49 + predicate<VideoUploadLimitsState>( 50 + (s) => 51 + s.status == VideoUploadLimitsStatus.loaded && 52 + s.limits != null && 53 + s.limits!.canUpload == true && 54 + s.limits!.remainingDailyVideos == 5, 55 + ), 56 + ], 57 + ); 58 + 59 + blocTest<VideoUploadLimitsCubit, VideoUploadLimitsState>( 60 + 'emits loaded with canUpload false and error/message fields', 61 + build: buildCubit, 62 + setUp: () { 63 + when(() => mockRepository.getUploadLimits()).thenAnswer((_) async => _cannotUploadLimits); 64 + }, 65 + act: (cubit) => cubit.fetch(), 66 + expect: () => [ 67 + const VideoUploadLimitsState.loading(), 68 + predicate<VideoUploadLimitsState>( 69 + (s) => 70 + s.status == VideoUploadLimitsStatus.loaded && 71 + s.limits != null && 72 + s.limits!.canUpload == false && 73 + s.limits!.error == 'DAILY_LIMIT_EXCEEDED' && 74 + s.limits!.message == 'Daily limit reached', 75 + ), 76 + ], 77 + ); 78 + 79 + blocTest<VideoUploadLimitsCubit, VideoUploadLimitsState>( 80 + 'emits error when repository throws', 81 + build: buildCubit, 82 + setUp: () { 83 + when(() => mockRepository.getUploadLimits()).thenThrow(Exception('auth failure')); 84 + }, 85 + act: (cubit) => cubit.fetch(), 86 + expect: () => [ 87 + const VideoUploadLimitsState.loading(), 88 + predicate<VideoUploadLimitsState>((s) => s.status == VideoUploadLimitsStatus.error && s.errorMessage != null), 89 + ], 90 + ); 91 + 92 + blocTest<VideoUploadLimitsCubit, VideoUploadLimitsState>( 93 + 'is a no-op when already loading', 94 + build: buildCubit, 95 + seed: () => const VideoUploadLimitsState.loading(), 96 + setUp: () { 97 + when(() => mockRepository.getUploadLimits()).thenAnswer((_) async => _canUploadLimits); 98 + }, 99 + act: (cubit) => cubit.fetch(), 100 + expect: () => [], 101 + verify: (_) { 102 + verifyNever(() => mockRepository.getUploadLimits()); 103 + }, 104 + ); 105 + 106 + blocTest<VideoUploadLimitsCubit, VideoUploadLimitsState>( 107 + 'loaded state exposes limits with optional null fields', 108 + build: buildCubit, 109 + setUp: () { 110 + when( 111 + () => mockRepository.getUploadLimits(), 112 + ).thenAnswer((_) async => const VideoUploadLimits(canUpload: true)); 113 + }, 114 + act: (cubit) => cubit.fetch(), 115 + expect: () => [ 116 + const VideoUploadLimitsState.loading(), 117 + predicate<VideoUploadLimitsState>( 118 + (s) => 119 + s.status == VideoUploadLimitsStatus.loaded && 120 + s.limits!.remainingDailyVideos == null && 121 + s.limits!.remainingDailyBytes == null, 122 + ), 123 + ], 124 + ); 125 + }); 126 + }); 127 + }
+81
test/features/settings/data/video_repository_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/features/settings/data/video_repository.dart'; 3 + import 'package:mocktail/mocktail.dart'; 4 + 5 + class MockVideoRepository extends Mock implements VideoRepository {} 6 + 7 + void main() { 8 + late MockVideoRepository mockRepository; 9 + 10 + setUp(() { 11 + mockRepository = MockVideoRepository(); 12 + }); 13 + 14 + group('VideoRepository.getUploadLimits', () { 15 + test('returns limits when canUpload is true', () async { 16 + when(() => mockRepository.getUploadLimits()).thenAnswer( 17 + (_) async => 18 + const VideoUploadLimits(canUpload: true, remainingDailyVideos: 10, remainingDailyBytes: 1024 * 1024 * 500), 19 + ); 20 + 21 + final result = await mockRepository.getUploadLimits(); 22 + 23 + expect(result.canUpload, isTrue); 24 + expect(result.remainingDailyVideos, 10); 25 + expect(result.remainingDailyBytes, 1024 * 1024 * 500); 26 + expect(result.message, isNull); 27 + expect(result.error, isNull); 28 + }); 29 + 30 + test('returns limits with message and error when canUpload is false', () async { 31 + when(() => mockRepository.getUploadLimits()).thenAnswer( 32 + (_) async => const VideoUploadLimits( 33 + canUpload: false, 34 + remainingDailyVideos: 0, 35 + remainingDailyBytes: 0, 36 + message: 'Daily limit reached', 37 + error: 'DAILY_LIMIT_EXCEEDED', 38 + ), 39 + ); 40 + 41 + final result = await mockRepository.getUploadLimits(); 42 + 43 + expect(result.canUpload, isFalse); 44 + expect(result.message, 'Daily limit reached'); 45 + expect(result.error, 'DAILY_LIMIT_EXCEEDED'); 46 + }); 47 + 48 + test('returns limits with all optional fields null', () async { 49 + when(() => mockRepository.getUploadLimits()).thenAnswer((_) async => const VideoUploadLimits(canUpload: true)); 50 + 51 + final result = await mockRepository.getUploadLimits(); 52 + 53 + expect(result.canUpload, isTrue); 54 + expect(result.remainingDailyVideos, isNull); 55 + expect(result.remainingDailyBytes, isNull); 56 + }); 57 + 58 + test('propagates exceptions', () async { 59 + when(() => mockRepository.getUploadLimits()).thenThrow(Exception('auth error')); 60 + 61 + expect(() => mockRepository.getUploadLimits(), throwsException); 62 + }); 63 + }); 64 + 65 + group('VideoUploadLimits', () { 66 + test('holds all fields', () { 67 + const limits = VideoUploadLimits( 68 + canUpload: true, 69 + remainingDailyVideos: 5, 70 + remainingDailyBytes: 1000, 71 + message: 'ok', 72 + error: null, 73 + ); 74 + expect(limits.canUpload, isTrue); 75 + expect(limits.remainingDailyVideos, 5); 76 + expect(limits.remainingDailyBytes, 1000); 77 + expect(limits.message, 'ok'); 78 + expect(limits.error, isNull); 79 + }); 80 + }); 81 + }