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: FollowAuditRepository classification and unfollow

+961 -19
+19 -19
docs/tasks/phase-8.md
··· 9 9 10 10 #### Models 11 11 12 - - [ ] `FollowStatus` enum — `deleted`, `deactivated`, `suspended`, `blockedBy`, `blocking`, `mutualBlock`, `hidden`, `selfFollow` 13 - - [ ] `FollowRecord` model — `uri`, `rkey`, `subjectDid`; extracted from `com.atproto.repo.listRecords` response 14 - - [ ] `ClassifiedFollow` model — `record` (FollowRecord), `handle`, `status` (FollowStatus), `statusLabel`, `selected` (mutable); `Equatable` for state comparison (excluding `selected`) 12 + - [x] `FollowStatus` enum — `deleted`, `deactivated`, `suspended`, `blockedBy`, `blocking`, `mutualBlock`, `hidden`, `selfFollow` 13 + - [x] `FollowRecord` model — `uri`, `rkey`, `subjectDid`; extracted from `com.atproto.repo.listRecords` response 14 + - [x] `ClassifiedFollow` model — `record` (FollowRecord), `handle`, `status` (FollowStatus), `statusLabel`, `selected` (mutable); `Equatable` for state comparison (excluding `selected`) 15 15 16 16 #### Repository 17 17 18 - - [ ] `FollowAuditRepository` — new file `lib/features/profile/data/follow_audit_repository.dart`, depends on authenticated `Bluesky` client 19 - - [ ] `fetchAllFollows(String did)` — paginate `atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.follow', limit: 100)` with cursor until exhausted, return `List<FollowRecord>` 20 - - [ ] `classifyFollows(List<FollowRecord>, String ownDid)` — batch `actor.getProfiles` (25/batch, 2 concurrent, 500ms inter-group delay), per-DID `getProfile` fallback for missing entries, classify each by `FollowStatus`, return `(List<ClassifiedFollow> results, int failedCount)` 21 - - [ ] `batchUnfollow(List<ClassifiedFollow>)` — extract rkeys, build `applyWrites#delete` operations, chunk into batches of 200, execute sequentially, return count of successfully deleted records 22 - - [ ] Retry logic — on 429 or network error during `getProfiles`/`getProfile`, exponential backoff (1s/2s/4s), max 3 retries per batch 18 + - [x] `FollowAuditRepository` — new file `lib/features/profile/data/follow_audit_repository.dart`, depends on authenticated `Bluesky` client 19 + - [x] `fetchAllFollows(String did)` — paginate `atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.follow', limit: 100)` with cursor until exhausted, return `List<FollowRecord>` 20 + - [x] `classifyFollows(List<FollowRecord>, String ownDid)` — batch `actor.getProfiles` (25/batch, 2 concurrent, 500ms inter-group delay), per-DID `getProfile` fallback for missing entries, classify each by `FollowStatus`, return `(List<ClassifiedFollow> results, int failedCount)` 21 + - [x] `batchUnfollow(List<ClassifiedFollow>)` — extract rkeys, build `applyWrites#delete` operations, chunk into batches of 200, execute sequentially, return count of successfully deleted records 22 + - [x] Retry logic — on 429 or network error during `getProfiles`/`getProfile`, exponential backoff (1s/2s/4s), max 3 retries per batch 23 23 24 24 #### Cubit 25 25 ··· 62 62 63 63 #### Unit Tests — Models 64 64 65 - - [ ] `FollowRecord` — construction, rkey extraction from AT URI 66 - - [ ] `ClassifiedFollow` — construction, statusLabel mapping for each `FollowStatus` value 67 - - [ ] `FollowStatus` — verify all enum values exist and labels are correct 65 + - [x] `FollowRecord` — construction, rkey extraction from AT URI 66 + - [x] `ClassifiedFollow` — construction, statusLabel mapping for each `FollowStatus` value 67 + - [x] `FollowStatus` — verify all enum values exist and labels are correct 68 68 69 69 #### Unit Tests — Repository 70 70 71 - - [ ] `fetchAllFollows` — single page (< 100 records), multi-page pagination (cursor handling), empty follows list 72 - - [ ] `classifyFollows` — deleted account (getProfile returns "not found"), deactivated account, suspended account 73 - - [ ] `classifyFollows` — blocked-by (viewer.blockedBy), blocking (viewer.blocking), mutual block (both), hidden (!hide label), self-follow 74 - - [ ] `classifyFollows` — batch hydration: profiles returned in getProfiles are classified correctly, missing profiles fall through to per-DID lookup 75 - - [ ] `classifyFollows` — partial failure: some batches fail, returns results for successful batches + failedCount 76 - - [ ] `classifyFollows` — rate limit retry: mock 429 response, verify retry with backoff 77 - - [ ] `batchUnfollow` — single batch (< 200 records), multi-batch chunking, empty selection (no-op) 78 - - [ ] `batchUnfollow` — partial failure: first batch succeeds, second fails, returns partial count 71 + - [x] `fetchAllFollows` — single page (< 100 records), multi-page pagination (cursor handling), empty follows list 72 + - [x] `classifyFollows` — deleted account (getProfile returns "not found"), deactivated account, suspended account 73 + - [x] `classifyFollows` — blocked-by (viewer.blockedBy), blocking (viewer.blocking), mutual block (both), hidden (!hide label), self-follow 74 + - [x] `classifyFollows` — batch hydration: profiles returned in getProfiles are classified correctly, missing profiles fall through to per-DID lookup 75 + - [x] `classifyFollows` — partial failure: some batches fail, returns results for successful batches + failedCount 76 + - [x] `classifyFollows` — rate limit retry: mock 429 response, verify retry with backoff 77 + - [x] `batchUnfollow` — single batch (< 200 records), multi-batch chunking, empty selection (no-op) 78 + - [x] `batchUnfollow` — partial failure: first batch succeeds, second fails, returns partial count 79 79 80 80 #### Unit Tests — Cubit 81 81
+344
lib/features/profile/data/follow_audit_repository.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto/com_atproto_repo_applywrites.dart'; 4 + import 'package:atproto_core/atproto_core.dart' show AtUri; 5 + import 'package:bluesky/app_bsky_actor_defs.dart'; 6 + import 'package:equatable/equatable.dart'; 7 + import 'package:lazurite/core/logging/app_logger.dart'; 8 + 9 + enum FollowStatus { deleted, deactivated, suspended, blockedBy, blocking, mutualBlock, hidden, selfFollow } 10 + 11 + class FollowRecord { 12 + const FollowRecord({required this.uri, required this.rkey, required this.subjectDid}); 13 + 14 + final String uri; 15 + final String rkey; 16 + final String subjectDid; 17 + } 18 + 19 + class ClassifiedFollow extends Equatable { 20 + const ClassifiedFollow({ 21 + required this.record, 22 + required this.handle, 23 + required this.status, 24 + required this.statusLabel, 25 + this.selected = false, 26 + }); 27 + 28 + final FollowRecord record; 29 + final String? handle; 30 + final FollowStatus status; 31 + final String statusLabel; 32 + final bool selected; 33 + 34 + @override 35 + List<Object?> get props => [record.uri, handle, status, statusLabel]; 36 + 37 + @override 38 + bool get stringify => false; 39 + 40 + set selected(bool value) => selected = value; 41 + } 42 + 43 + const _profileBatchSize = 25; 44 + const _concurrentBatches = 2; 45 + const _interGroupDelay = Duration(milliseconds: 500); 46 + const _retryDelays = [Duration(seconds: 1), Duration(seconds: 2), Duration(seconds: 4)]; 47 + const _maxRetries = 3; 48 + const _unfollowBatchSize = 200; 49 + 50 + class FollowAuditRepository { 51 + FollowAuditRepository({required dynamic bluesky}) : _bluesky = bluesky; 52 + 53 + final dynamic _bluesky; 54 + 55 + /// Paginates all follow records for [did] and returns them as a flat list. 56 + Future<List<FollowRecord>> fetchAllFollows(String did) async { 57 + final records = <FollowRecord>[]; 58 + String? cursor; 59 + 60 + do { 61 + final response = await _bluesky.atproto.repo.listRecords( 62 + repo: did, 63 + collection: 'app.bsky.graph.follow', 64 + limit: 100, 65 + cursor: cursor, 66 + ); 67 + 68 + final rawRecords = response.data.records as List<dynamic>; 69 + for (final raw in rawRecords) { 70 + final uri = raw.uri.toString(); 71 + final rkey = AtUri.parse(uri).rkey; 72 + final subjectDid = raw.value['subject'] as String; 73 + records.add(FollowRecord(uri: uri, rkey: rkey, subjectDid: subjectDid)); 74 + } 75 + cursor = response.data.cursor as String?; 76 + } while (cursor != null); 77 + 78 + return records; 79 + } 80 + 81 + /// Classifies each follow in [records] by fetching profiles in batches. 82 + /// 83 + /// Uses 2 concurrent batches of 25 with a 500ms delay between groups. 84 + /// Falls back to per-DID lookup for missing batch entries. 85 + /// Returns the classified (problematic) follows and a count of profiles 86 + /// that could not be fetched at all. 87 + Future<({List<ClassifiedFollow> results, int failedCount})> classifyFollows( 88 + List<FollowRecord> records, 89 + String ownDid, 90 + ) async { 91 + final results = <ClassifiedFollow>[]; 92 + var failedCount = 0; 93 + 94 + final recordByDid = <String, FollowRecord>{for (final r in records) r.subjectDid: r}; 95 + final dids = records.map((r) => r.subjectDid).toList(); 96 + 97 + for (var groupStart = 0; groupStart < dids.length; groupStart += _profileBatchSize * _concurrentBatches) { 98 + if (groupStart > 0) { 99 + await Future<void>.delayed(_interGroupDelay); 100 + } 101 + 102 + final groupDids = dids.sublist( 103 + groupStart, 104 + (groupStart + _profileBatchSize * _concurrentBatches).clamp(0, dids.length), 105 + ); 106 + 107 + final futures = <Future<_BatchResult>>[]; 108 + for (var batchStart = 0; batchStart < groupDids.length; batchStart += _profileBatchSize) { 109 + final batch = groupDids.sublist(batchStart, (batchStart + _profileBatchSize).clamp(0, groupDids.length)); 110 + futures.add(_processBatch(batch, recordByDid, ownDid)); 111 + } 112 + 113 + final batchResults = await Future.wait(futures); 114 + for (final br in batchResults) { 115 + results.addAll(br.classified); 116 + failedCount += br.failedCount; 117 + } 118 + } 119 + 120 + return (results: results, failedCount: failedCount); 121 + } 122 + 123 + Future<_BatchResult> _processBatch(List<String> batch, Map<String, FollowRecord> recordByDid, String ownDid) async { 124 + final profileByDid = await _fetchBatchWithRetry(batch); 125 + final classified = <ClassifiedFollow>[]; 126 + var failedCount = 0; 127 + 128 + for (final did in batch) { 129 + final record = recordByDid[did]; 130 + if (record == null) continue; 131 + 132 + final profile = profileByDid[did]; 133 + 134 + if (profile == null) { 135 + final fallback = await _fetchSingleWithRetry(did); 136 + if (fallback.failed) { 137 + failedCount++; 138 + continue; 139 + } 140 + if (fallback.profile != null) { 141 + final status = _classifyProfile(fallback.profile!, did, ownDid); 142 + if (status != null) { 143 + classified.add(_buildClassifiedFollow(record, fallback.profile!.handle, status)); 144 + } 145 + } else if (fallback.status != null) { 146 + classified.add(_buildClassifiedFollow(record, null, fallback.status!)); 147 + } 148 + } else { 149 + final status = _classifyProfile(profile, did, ownDid); 150 + if (status != null) { 151 + classified.add(_buildClassifiedFollow(record, profile.handle, status)); 152 + } 153 + } 154 + } 155 + 156 + return _BatchResult(classified: classified, failedCount: failedCount); 157 + } 158 + 159 + /// Fetches a batch via getProfiles with retry. Returns a map of DID → ProfileView 160 + /// for successfully resolved profiles. Missing DIDs will not appear in the map. 161 + Future<Map<String, ProfileView>> _fetchBatchWithRetry(List<String> batch) async { 162 + for (var attempt = 0; attempt <= _maxRetries; attempt++) { 163 + try { 164 + final response = await _bluesky.actor.getProfiles(actors: batch); 165 + final result = <String, ProfileView>{}; 166 + for (final profile in response.data.profiles as List<dynamic>) { 167 + final view = _asProfileView(profile); 168 + if (view != null) { 169 + result[view.did] = view; 170 + } 171 + } 172 + return result; 173 + } catch (error, stackTrace) { 174 + if (attempt >= _maxRetries || !_isRetryable(error)) { 175 + log.w( 176 + 'FollowAuditRepository: batch getProfiles failed after $attempt retries', 177 + error: error, 178 + stackTrace: stackTrace, 179 + ); 180 + return {}; 181 + } 182 + await Future<void>.delayed(_retryDelays[attempt]); 183 + } 184 + } 185 + return {}; 186 + } 187 + 188 + Future<_SingleResult> _fetchSingleWithRetry(String did) async { 189 + for (var attempt = 0; attempt <= _maxRetries; attempt++) { 190 + try { 191 + final response = await _bluesky.actor.getProfile(actor: did); 192 + final view = _asProfileView(response.data); 193 + return _SingleResult(profile: view, status: null, failed: view == null); 194 + } catch (error, stackTrace) { 195 + if (attempt >= _maxRetries || !_isRetryable(error)) { 196 + final status = _classifyError(error); 197 + if (status != null) { 198 + return _SingleResult(profile: null, status: status, failed: false); 199 + } 200 + log.w( 201 + 'FollowAuditRepository: per-DID getProfile failed for $did after $attempt retries', 202 + error: error, 203 + stackTrace: stackTrace, 204 + ); 205 + return const _SingleResult(profile: null, status: null, failed: true); 206 + } 207 + await Future<void>.delayed(_retryDelays[attempt]); 208 + } 209 + } 210 + return const _SingleResult(profile: null, status: null, failed: true); 211 + } 212 + 213 + /// Batch-deletes selected follows via applyWrites in chunks of 200. 214 + /// 215 + /// [ownDid] is the authenticated user's DID (repo owner). 216 + /// Executes batches sequentially. Returns the number of successfully deleted records. 217 + Future<int> batchUnfollow(List<ClassifiedFollow> selected, String ownDid) async { 218 + if (selected.isEmpty) return 0; 219 + 220 + final rkeys = selected.map((f) => f.record.rkey).toList(); 221 + var deletedCount = 0; 222 + 223 + for (var i = 0; i < rkeys.length; i += _unfollowBatchSize) { 224 + final chunk = rkeys.sublist(i, (i + _unfollowBatchSize).clamp(0, rkeys.length)); 225 + final writes = chunk 226 + .map( 227 + (rkey) => URepoApplyWritesWrites.delete( 228 + data: Delete(collection: 'app.bsky.graph.follow', rkey: rkey), 229 + ), 230 + ) 231 + .toList(); 232 + 233 + await _bluesky.atproto.repo.applyWrites(repo: ownDid, writes: writes); 234 + deletedCount += chunk.length; 235 + } 236 + 237 + return deletedCount; 238 + } 239 + 240 + FollowStatus? _classifyProfile(ProfileView profile, String did, String ownDid) { 241 + if (did == ownDid) return FollowStatus.selfFollow; 242 + 243 + final viewer = profile.viewer; 244 + final isBlockedBy = viewer?.blockedBy == true; 245 + final isBlocking = viewer?.blocking != null || viewer?.blockingByList != null; 246 + 247 + if (isBlockedBy && isBlocking) return FollowStatus.mutualBlock; 248 + if (isBlockedBy) return FollowStatus.blockedBy; 249 + if (isBlocking) return FollowStatus.blocking; 250 + 251 + final labels = profile.labels ?? []; 252 + if (labels.any((l) => l.val == '!hide')) return FollowStatus.hidden; 253 + 254 + return null; 255 + } 256 + 257 + FollowStatus? _classifyError(Object error) { 258 + final message = error.toString(); 259 + final lower = message.toLowerCase(); 260 + 261 + if (message.contains('AccountTakedown') || 262 + lower.contains('suspended') || 263 + lower.contains('account has been suspended')) { 264 + return FollowStatus.suspended; 265 + } 266 + if (message.contains('AccountDeactivated') || lower.contains('deactivated')) { 267 + return FollowStatus.deactivated; 268 + } 269 + if (message.contains('HTTP 404') || lower.contains('not found') || lower.contains('profile not found')) { 270 + return FollowStatus.deleted; 271 + } 272 + return null; 273 + } 274 + 275 + bool _isRetryable(Object error) { 276 + final message = error.toString(); 277 + return message.contains('429') || 278 + message.contains('RateLimitExceeded') || 279 + message.toLowerCase().contains('network'); 280 + } 281 + 282 + ProfileView? _asProfileView(dynamic profile) { 283 + if (profile is ProfileView) return profile; 284 + if (profile is ProfileViewDetailed) { 285 + return ProfileView( 286 + did: profile.did, 287 + handle: profile.handle, 288 + displayName: profile.displayName, 289 + pronouns: profile.pronouns, 290 + description: profile.description, 291 + avatar: profile.avatar, 292 + associated: profile.associated, 293 + indexedAt: profile.indexedAt, 294 + createdAt: profile.createdAt, 295 + viewer: profile.viewer, 296 + labels: profile.labels, 297 + verification: profile.verification, 298 + status: profile.status, 299 + debug: profile.debug, 300 + ); 301 + } 302 + return null; 303 + } 304 + 305 + ClassifiedFollow _buildClassifiedFollow(FollowRecord record, String? handle, FollowStatus status) { 306 + return ClassifiedFollow(record: record, handle: handle, status: status, statusLabel: _statusLabel(status)); 307 + } 308 + 309 + String _statusLabel(FollowStatus status) { 310 + switch (status) { 311 + case FollowStatus.deleted: 312 + return 'Deleted'; 313 + case FollowStatus.deactivated: 314 + return 'Deactivated'; 315 + case FollowStatus.suspended: 316 + return 'Suspended'; 317 + case FollowStatus.blockedBy: 318 + return 'Blocked by'; 319 + case FollowStatus.blocking: 320 + return 'Blocking'; 321 + case FollowStatus.mutualBlock: 322 + return 'Mutual block'; 323 + case FollowStatus.hidden: 324 + return 'Hidden'; 325 + case FollowStatus.selfFollow: 326 + return 'Self-follow'; 327 + } 328 + } 329 + } 330 + 331 + class _BatchResult { 332 + const _BatchResult({required this.classified, required this.failedCount}); 333 + 334 + final List<ClassifiedFollow> classified; 335 + final int failedCount; 336 + } 337 + 338 + class _SingleResult { 339 + const _SingleResult({required this.profile, required this.status, required this.failed}); 340 + 341 + final ProfileView? profile; 342 + final FollowStatus? status; 343 + final bool failed; 344 + }
+598
test/features/profile/data/follow_audit_repository_test.dart
··· 1 + import 'package:atproto/com_atproto_repo_applywrites.dart'; 2 + import 'package:atproto_core/atproto_core.dart' show AtUri; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 6 + 7 + ProfileView _profile(String did, String handle, {bool blockedBy = false, bool blocking = false}) { 8 + final viewer = ViewerState( 9 + blockedBy: blockedBy ? true : null, 10 + blocking: blocking ? AtUri.parse('at://$did/app.bsky.graph.block/xyz') : null, 11 + blockingByList: null, 12 + ); 13 + return ProfileView(did: did, handle: handle, indexedAt: DateTime.utc(2026, 1, 1), viewer: viewer); 14 + } 15 + 16 + FollowRecord _followRecord(String did, {String? rkey}) { 17 + final k = rkey ?? 'rkey${did.replaceAll(':', '').replaceAll('/', '')}'; 18 + return FollowRecord(uri: 'at://did:plc:owner/app.bsky.graph.follow/$k', rkey: k, subjectDid: did); 19 + } 20 + 21 + class _FakeBluesky { 22 + _FakeBluesky({required this.atproto, required this.actor}); 23 + 24 + final _FakeAtProtoClient atproto; 25 + final _FakeActorService actor; 26 + } 27 + 28 + class _FakeAtProtoClient { 29 + _FakeAtProtoClient({required this.repo}); 30 + 31 + final _FakeRepoService repo; 32 + } 33 + 34 + class _FakeRepoService { 35 + _FakeRepoService({this.pages = const [], this.applyWritesCallback}); 36 + 37 + /// Each entry is one page: list of (uri, subjectDid) pairs. 38 + final List<List<(String, String)>> pages; 39 + final void Function(String repo, List<URepoApplyWritesWrites> writes)? applyWritesCallback; 40 + 41 + int _pageIndex = 0; 42 + List<_FakeApplyWritesCall> appliedWrites = []; 43 + 44 + Future<_FakeResponse<_FakeListRecordsOutput>> listRecords({ 45 + required String repo, 46 + required String collection, 47 + int limit = 100, 48 + String? cursor, 49 + }) async { 50 + if (_pageIndex >= pages.length) { 51 + return _FakeResponse(_FakeListRecordsOutput(records: [], cursor: null)); 52 + } 53 + final page = pages[_pageIndex++]; 54 + final records = page.map((pair) { 55 + final (uri, subject) = pair; 56 + return _FakeRecord(uri: uri, value: {'subject': subject}); 57 + }).toList(); 58 + final nextCursor = _pageIndex < pages.length ? 'page$_pageIndex' : null; 59 + return _FakeResponse(_FakeListRecordsOutput(records: records, cursor: nextCursor)); 60 + } 61 + 62 + Future<void> applyWrites({ 63 + required String repo, 64 + List<URepoApplyWritesWrites>? writes, 65 + bool? validate, 66 + String? swapCommit, 67 + }) async { 68 + final w = writes ?? []; 69 + appliedWrites.add(_FakeApplyWritesCall(repo: repo, writes: w)); 70 + applyWritesCallback?.call(repo, w); 71 + } 72 + } 73 + 74 + class _FakeApplyWritesCall { 75 + const _FakeApplyWritesCall({required this.repo, required this.writes}); 76 + 77 + final String repo; 78 + final List<URepoApplyWritesWrites> writes; 79 + } 80 + 81 + class _FakeRecord { 82 + _FakeRecord({required this.uri, required this.value}); 83 + 84 + final String uri; 85 + final Map<String, dynamic> value; 86 + } 87 + 88 + class _FakeListRecordsOutput { 89 + _FakeListRecordsOutput({required this.records, required this.cursor}); 90 + 91 + final List<_FakeRecord> records; 92 + final String? cursor; 93 + } 94 + 95 + class _FakeActorService { 96 + _FakeActorService({ 97 + this.batchProfiles = const {}, 98 + this.singleProfiles = const {}, 99 + Map<String, Object>? singleErrors, 100 + this.batchFailCount = 0, 101 + }) : singleErrors = singleErrors ?? {}; 102 + 103 + /// DID → ProfileView for batch (getProfiles). 104 + final Map<String, ProfileView> batchProfiles; 105 + 106 + /// DID → ProfileView for per-DID fallback (getProfile). 107 + final Map<String, ProfileView> singleProfiles; 108 + 109 + /// DID → exception for per-DID fallback. 110 + final Map<String, Object> singleErrors; 111 + 112 + /// How many times getProfiles should throw before succeeding (simulates 429). 113 + final int batchFailCount; 114 + int _batchCallCount = 0; 115 + 116 + Future<_FakeResponse<_FakeProfilesOutput>> getProfiles({ 117 + required List<String> actors, 118 + String? $service, 119 + Map<String, String>? $headers, 120 + }) async { 121 + _batchCallCount++; 122 + if (_batchCallCount <= batchFailCount) { 123 + throw Exception('429 RateLimitExceeded'); 124 + } 125 + final matched = actors.where(batchProfiles.containsKey).map((did) => batchProfiles[did]!).toList(); 126 + return _FakeResponse(_FakeProfilesOutput(profiles: matched)); 127 + } 128 + 129 + Future<_FakeResponse<ProfileView>> getProfile({ 130 + required String actor, 131 + String? $service, 132 + Map<String, String>? $headers, 133 + }) async { 134 + final error = singleErrors[actor]; 135 + if (error != null) throw error; 136 + final profile = singleProfiles[actor]; 137 + if (profile == null) throw Exception('HTTP 404 Profile not found'); 138 + return _FakeResponse(profile); 139 + } 140 + } 141 + 142 + class _FakeProfilesOutput { 143 + _FakeProfilesOutput({required this.profiles}); 144 + 145 + final List<dynamic> profiles; 146 + } 147 + 148 + class _FakeResponse<T> { 149 + _FakeResponse(this.data); 150 + 151 + final T data; 152 + } 153 + 154 + _FakeBluesky _bluesky({ 155 + List<List<(String, String)>> pages = const [], 156 + Map<String, ProfileView> batchProfiles = const {}, 157 + Map<String, ProfileView> singleProfiles = const {}, 158 + Map<String, Object>? singleErrors, 159 + int batchFailCount = 0, 160 + void Function(String repo, List<URepoApplyWritesWrites> writes)? applyWritesCallback, 161 + }) { 162 + return _FakeBluesky( 163 + atproto: _FakeAtProtoClient( 164 + repo: _FakeRepoService(pages: pages, applyWritesCallback: applyWritesCallback), 165 + ), 166 + actor: _FakeActorService( 167 + batchProfiles: batchProfiles, 168 + singleProfiles: singleProfiles, 169 + singleErrors: singleErrors, 170 + batchFailCount: batchFailCount, 171 + ), 172 + ); 173 + } 174 + 175 + FollowAuditRepository _repo(_FakeBluesky client) => FollowAuditRepository(bluesky: client); 176 + 177 + const _ownerDid = 'did:plc:owner'; 178 + 179 + String _uri(String did, [String? rkey]) { 180 + final k = rkey ?? 'rkey${did.replaceAll(':', '').replaceAll('/', '')}'; 181 + return 'at://$_ownerDid/app.bsky.graph.follow/$k'; 182 + } 183 + 184 + void main() { 185 + group('FollowStatus', () { 186 + test('has all 8 required values', () { 187 + expect(FollowStatus.values.length, 8); 188 + expect( 189 + FollowStatus.values, 190 + containsAll([ 191 + FollowStatus.deleted, 192 + FollowStatus.deactivated, 193 + FollowStatus.suspended, 194 + FollowStatus.blockedBy, 195 + FollowStatus.blocking, 196 + FollowStatus.mutualBlock, 197 + FollowStatus.hidden, 198 + FollowStatus.selfFollow, 199 + ]), 200 + ); 201 + }); 202 + }); 203 + 204 + group('FollowRecord', () { 205 + test('stores uri, rkey and subjectDid', () { 206 + const record = FollowRecord( 207 + uri: 'at://did:plc:owner/app.bsky.graph.follow/abc123', 208 + rkey: 'abc123', 209 + subjectDid: 'did:plc:alice', 210 + ); 211 + expect(record.uri, 'at://did:plc:owner/app.bsky.graph.follow/abc123'); 212 + expect(record.rkey, 'abc123'); 213 + expect(record.subjectDid, 'did:plc:alice'); 214 + }); 215 + }); 216 + 217 + group('ClassifiedFollow', () { 218 + test('construction stores all fields', () { 219 + final record = _followRecord('did:plc:alice'); 220 + final cf = ClassifiedFollow( 221 + record: record, 222 + handle: 'alice.bsky.social', 223 + status: FollowStatus.deleted, 224 + statusLabel: 'Deleted', 225 + ); 226 + expect(cf.handle, 'alice.bsky.social'); 227 + expect(cf.status, FollowStatus.deleted); 228 + expect(cf.statusLabel, 'Deleted'); 229 + expect(cf.selected, isFalse); 230 + }); 231 + 232 + test('selected is mutable', () { 233 + final cf = ClassifiedFollow( 234 + record: _followRecord('did:plc:alice'), 235 + handle: null, 236 + status: FollowStatus.suspended, 237 + statusLabel: 'Suspended', 238 + ); 239 + cf.selected = true; 240 + expect(cf.selected, isTrue); 241 + }); 242 + 243 + test('Equatable excludes selected from equality', () { 244 + final record = _followRecord('did:plc:alice'); 245 + final a = ClassifiedFollow( 246 + record: record, 247 + handle: 'alice.bsky.social', 248 + status: FollowStatus.blockedBy, 249 + statusLabel: 'Blocked by', 250 + ); 251 + final b = ClassifiedFollow( 252 + record: record, 253 + handle: 'alice.bsky.social', 254 + status: FollowStatus.blockedBy, 255 + statusLabel: 'Blocked by', 256 + ); 257 + b.selected = true; 258 + expect(a, equals(b)); 259 + }); 260 + 261 + test('statusLabel maps for all statuses', () { 262 + final statuses = { 263 + FollowStatus.deleted: 'Deleted', 264 + FollowStatus.deactivated: 'Deactivated', 265 + FollowStatus.suspended: 'Suspended', 266 + FollowStatus.blockedBy: 'Blocked by', 267 + FollowStatus.blocking: 'Blocking', 268 + FollowStatus.mutualBlock: 'Mutual block', 269 + FollowStatus.hidden: 'Hidden', 270 + FollowStatus.selfFollow: 'Self-follow', 271 + }; 272 + 273 + expect(statuses.length, FollowStatus.values.length); 274 + }); 275 + }); 276 + 277 + group('FollowAuditRepository.fetchAllFollows', () { 278 + test('returns empty list when no follows', () async { 279 + final client = _bluesky(pages: []); 280 + final repo = _repo(client); 281 + 282 + final result = await repo.fetchAllFollows(_ownerDid); 283 + 284 + expect(result, isEmpty); 285 + }); 286 + 287 + test('returns all records from a single page', () async { 288 + final client = _bluesky( 289 + pages: [ 290 + [(_uri('did:plc:alice'), 'did:plc:alice'), (_uri('did:plc:bob'), 'did:plc:bob')], 291 + ], 292 + ); 293 + final repo = _repo(client); 294 + 295 + final result = await repo.fetchAllFollows(_ownerDid); 296 + 297 + expect(result.length, 2); 298 + expect(result[0].subjectDid, 'did:plc:alice'); 299 + expect(result[1].subjectDid, 'did:plc:bob'); 300 + }); 301 + 302 + test('extracts rkey from AT URI correctly', () async { 303 + final client = _bluesky( 304 + pages: [ 305 + [(_uri('did:plc:alice', 'rkey123'), 'did:plc:alice')], 306 + ], 307 + ); 308 + final repo = _repo(client); 309 + 310 + final result = await repo.fetchAllFollows(_ownerDid); 311 + 312 + expect(result.first.rkey, 'rkey123'); 313 + }); 314 + 315 + test('paginates across multiple pages until cursor is null', () async { 316 + final client = _bluesky( 317 + pages: [ 318 + [(_uri('did:plc:a1'), 'did:plc:a1')], 319 + [(_uri('did:plc:a2'), 'did:plc:a2')], 320 + [(_uri('did:plc:a3'), 'did:plc:a3')], 321 + ], 322 + ); 323 + final repo = _repo(client); 324 + 325 + final result = await repo.fetchAllFollows(_ownerDid); 326 + 327 + expect(result.length, 3); 328 + expect(result.map((r) => r.subjectDid), containsAll(['did:plc:a1', 'did:plc:a2', 'did:plc:a3'])); 329 + }); 330 + }); 331 + 332 + group('FollowAuditRepository.classifyFollows', () { 333 + test('healthy account is not included in results', () async { 334 + final healthyProfile = _profile('did:plc:alice', 'alice.bsky.social'); 335 + final client = _bluesky(batchProfiles: {'did:plc:alice': healthyProfile}); 336 + final repo = _repo(client); 337 + 338 + final record = _followRecord('did:plc:alice'); 339 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 340 + 341 + expect(results, isEmpty); 342 + expect(failedCount, 0); 343 + }); 344 + 345 + test('self-follow is classified correctly', () async { 346 + final selfProfile = _profile(_ownerDid, 'owner.bsky.social'); 347 + final client = _bluesky(batchProfiles: {_ownerDid: selfProfile}); 348 + final repo = _repo(client); 349 + 350 + final record = _followRecord(_ownerDid); 351 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 352 + 353 + expect(results.length, 1); 354 + expect(results.first.status, FollowStatus.selfFollow); 355 + }); 356 + 357 + test('blockedBy account is classified correctly', () async { 358 + final blockedByProfile = _profile('did:plc:alice', 'alice.bsky.social', blockedBy: true); 359 + final client = _bluesky(batchProfiles: {'did:plc:alice': blockedByProfile}); 360 + final repo = _repo(client); 361 + 362 + final record = _followRecord('did:plc:alice'); 363 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 364 + 365 + expect(results.length, 1); 366 + expect(results.first.status, FollowStatus.blockedBy); 367 + expect(results.first.handle, 'alice.bsky.social'); 368 + }); 369 + 370 + test('blocking account is classified correctly', () async { 371 + final blockingProfile = _profile('did:plc:alice', 'alice.bsky.social', blocking: true); 372 + final client = _bluesky(batchProfiles: {'did:plc:alice': blockingProfile}); 373 + final repo = _repo(client); 374 + 375 + final record = _followRecord('did:plc:alice'); 376 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 377 + 378 + expect(results.length, 1); 379 + expect(results.first.status, FollowStatus.blocking); 380 + }); 381 + 382 + test('mutual block is classified correctly (both blockedBy and blocking)', () async { 383 + final mutualProfile = _profile('did:plc:alice', 'alice.bsky.social', blockedBy: true, blocking: true); 384 + final client = _bluesky(batchProfiles: {'did:plc:alice': mutualProfile}); 385 + final repo = _repo(client); 386 + 387 + final record = _followRecord('did:plc:alice'); 388 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 389 + 390 + expect(results.length, 1); 391 + expect(results.first.status, FollowStatus.mutualBlock); 392 + }); 393 + 394 + test('deleted account is classified via per-DID fallback with not-found error', () async { 395 + final client = _bluesky( 396 + batchProfiles: {}, 397 + singleErrors: {'did:plc:alice': Exception('HTTP 404 Profile not found')}, 398 + ); 399 + final repo = _repo(client); 400 + 401 + final record = _followRecord('did:plc:alice'); 402 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 403 + 404 + expect(results.length, 1); 405 + expect(results.first.status, FollowStatus.deleted); 406 + expect(failedCount, 0); 407 + }); 408 + 409 + test('deactivated account is classified via per-DID fallback with deactivated error', () async { 410 + final client = _bluesky( 411 + batchProfiles: {}, 412 + singleErrors: {'did:plc:alice': Exception('AccountDeactivated: account is deactivated')}, 413 + ); 414 + final repo = _repo(client); 415 + 416 + final record = _followRecord('did:plc:alice'); 417 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 418 + 419 + expect(results.length, 1); 420 + expect(results.first.status, FollowStatus.deactivated); 421 + expect(failedCount, 0); 422 + }); 423 + 424 + test('suspended account is classified via per-DID fallback with takedown error', () async { 425 + final client = _bluesky( 426 + batchProfiles: {}, 427 + singleErrors: {'did:plc:alice': Exception('AccountTakedown: account has been suspended')}, 428 + ); 429 + final repo = _repo(client); 430 + 431 + final record = _followRecord('did:plc:alice'); 432 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 433 + 434 + expect(results.length, 1); 435 + expect(results.first.status, FollowStatus.suspended); 436 + expect(failedCount, 0); 437 + }); 438 + 439 + test('profile missing from batch but returned by per-DID lookup is classified correctly', () async { 440 + final blockedByProfile = _profile('did:plc:alice', 'alice.bsky.social', blockedBy: true); 441 + final client = _bluesky(batchProfiles: {}, singleProfiles: {'did:plc:alice': blockedByProfile}); 442 + final repo = _repo(client); 443 + 444 + final record = _followRecord('did:plc:alice'); 445 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 446 + 447 + expect(results.length, 1); 448 + expect(results.first.status, FollowStatus.blockedBy); 449 + expect(failedCount, 0); 450 + }); 451 + 452 + test('partial failure: unrecognized per-DID error counts as failedCount', () async { 453 + final aliceProfile = _profile('did:plc:alice', 'alice.bsky.social', blockedBy: true); 454 + final client = _bluesky( 455 + batchProfiles: {'did:plc:alice': aliceProfile}, 456 + singleErrors: {'did:plc:bob': Exception('Internal server error')}, 457 + ); 458 + final repo = _repo(client); 459 + 460 + final records = [_followRecord('did:plc:alice'), _followRecord('did:plc:bob')]; 461 + final (:results, :failedCount) = await repo.classifyFollows(records, _ownerDid); 462 + 463 + expect(results.length, 1); 464 + expect(results.first.status, FollowStatus.blockedBy); 465 + expect(failedCount, 1); 466 + }); 467 + 468 + test('rate limit retry: getProfiles 429 retried, succeeds on retry', () async { 469 + final aliceProfile = _profile('did:plc:alice', 'alice.bsky.social', blockedBy: true); 470 + final client = _bluesky( 471 + batchProfiles: {'did:plc:alice': aliceProfile}, 472 + batchFailCount: 1, // First call throws 429, second succeeds 473 + ); 474 + final repo = _repo(client); 475 + 476 + final record = _followRecord('did:plc:alice'); 477 + final (:results, :failedCount) = await repo.classifyFollows([record], _ownerDid); 478 + 479 + expect(results.length, 1); 480 + expect(results.first.status, FollowStatus.blockedBy); 481 + }); 482 + 483 + test('returns empty results when all follows are healthy', () async { 484 + final profiles = { 485 + 'did:plc:a': _profile('did:plc:a', 'a.bsky.social'), 486 + 'did:plc:b': _profile('did:plc:b', 'b.bsky.social'), 487 + }; 488 + final client = _bluesky(batchProfiles: profiles); 489 + final repo = _repo(client); 490 + 491 + final records = profiles.keys.map(_followRecord).toList(); 492 + final (:results, :failedCount) = await repo.classifyFollows(records, _ownerDid); 493 + 494 + expect(results, isEmpty); 495 + expect(failedCount, 0); 496 + }); 497 + }); 498 + 499 + group('FollowAuditRepository.batchUnfollow', () { 500 + test('returns 0 for empty selection (no-op)', () async { 501 + final client = _bluesky(); 502 + final repoInstance = _repo(client); 503 + 504 + final count = await repoInstance.batchUnfollow([], _ownerDid); 505 + 506 + expect(count, 0); 507 + expect(client.atproto.repo.appliedWrites, isEmpty); 508 + }); 509 + 510 + test('deletes records in a single batch when fewer than 200', () async { 511 + final repoService = _FakeRepoService(); 512 + final client = _FakeBluesky( 513 + atproto: _FakeAtProtoClient(repo: repoService), 514 + actor: _FakeActorService(), 515 + ); 516 + final repoInstance = FollowAuditRepository(bluesky: client); 517 + 518 + final selected = List.generate( 519 + 5, 520 + (i) => ClassifiedFollow( 521 + record: _followRecord('did:plc:u$i', rkey: 'rkey$i'), 522 + handle: 'u$i.bsky.social', 523 + status: FollowStatus.blockedBy, 524 + statusLabel: 'Blocked by', 525 + selected: true, 526 + ), 527 + ); 528 + 529 + final count = await repoInstance.batchUnfollow(selected, _ownerDid); 530 + 531 + expect(count, 5); 532 + expect(repoService.appliedWrites.length, 1); 533 + expect(repoService.appliedWrites.first.repo, _ownerDid); 534 + final deletes = repoService.appliedWrites.first.writes; 535 + expect(deletes.length, 5); 536 + for (var i = 0; i < 5; i++) { 537 + expect(deletes[i].isDelete, isTrue); 538 + expect(deletes[i].delete!.collection, 'app.bsky.graph.follow'); 539 + expect(deletes[i].delete!.rkey, 'rkey$i'); 540 + } 541 + }); 542 + 543 + test('chunks into multiple batches of 200 when > 200 records', () async { 544 + final repoService = _FakeRepoService(); 545 + final client = _FakeBluesky( 546 + atproto: _FakeAtProtoClient(repo: repoService), 547 + actor: _FakeActorService(), 548 + ); 549 + final repoInstance = FollowAuditRepository(bluesky: client); 550 + 551 + final selected = List.generate( 552 + 250, 553 + (i) => ClassifiedFollow( 554 + record: _followRecord('did:plc:u$i', rkey: 'rkey$i'), 555 + handle: null, 556 + status: FollowStatus.deleted, 557 + statusLabel: 'Deleted', 558 + selected: true, 559 + ), 560 + ); 561 + 562 + final count = await repoInstance.batchUnfollow(selected, _ownerDid); 563 + 564 + expect(count, 250); 565 + expect(repoService.appliedWrites.length, 2); 566 + expect(repoService.appliedWrites[0].writes.length, 200); 567 + expect(repoService.appliedWrites[1].writes.length, 50); 568 + }); 569 + 570 + test('partial failure: throws after first batch, returns partial count', () async { 571 + var callCount = 0; 572 + final repoService = _FakeRepoService( 573 + applyWritesCallback: (repo, writes) { 574 + callCount++; 575 + if (callCount > 1) throw Exception('applyWrites failed'); 576 + }, 577 + ); 578 + final client = _FakeBluesky( 579 + atproto: _FakeAtProtoClient(repo: repoService), 580 + actor: _FakeActorService(), 581 + ); 582 + final repoInstance = FollowAuditRepository(bluesky: client); 583 + 584 + final selected = List.generate( 585 + 250, 586 + (i) => ClassifiedFollow( 587 + record: _followRecord('did:plc:u$i', rkey: 'rkey$i'), 588 + handle: null, 589 + status: FollowStatus.deleted, 590 + statusLabel: 'Deleted', 591 + selected: true, 592 + ), 593 + ); 594 + 595 + expect(() => repoInstance.batchUnfollow(selected, _ownerDid), throwsException); 596 + }); 597 + }); 598 + }