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.

at main 424 lines 16 kB view raw
1import 'package:drift/native.dart'; 2import 'package:drift/drift.dart' show Value; 3import 'package:flutter_test/flutter_test.dart'; 4import 'package:lazurite/core/database/app_database.dart'; 5 6void main() { 7 late AppDatabase database; 8 9 setUp(() async { 10 database = AppDatabase(executor: NativeDatabase.memory()); 11 }); 12 13 tearDown(() async { 14 await database.close(); 15 }); 16 17 group('AppDatabase', () { 18 group('Account operations', () { 19 test('should insert and retrieve an account', () async { 20 final account = AccountsCompanion.insert( 21 did: 'did:plc:abc123', 22 handle: 'user.bsky.social', 23 accessToken: 'access_token', 24 ); 25 26 await database.insertAccount(account); 27 final retrieved = await database.getAccount('did:plc:abc123'); 28 29 expect(retrieved, isNotNull); 30 expect(retrieved!.did, equals('did:plc:abc123')); 31 expect(retrieved.handle, equals('user.bsky.social')); 32 expect(retrieved.accessToken, equals('access_token')); 33 }); 34 35 test('should return null for non-existent account', () async { 36 final result = await database.getAccount('did:plc:nonexistent'); 37 expect(result, isNull); 38 }); 39 40 test('should get active account', () async { 41 final account = AccountsCompanion.insert( 42 did: 'did:plc:abc123', 43 handle: 'user.bsky.social', 44 accessToken: 'access_token', 45 ); 46 47 await database.insertAccount(account); 48 final active = await database.getActiveAccount(); 49 50 expect(active, isNotNull); 51 expect(active!.did, equals('did:plc:abc123')); 52 }); 53 54 test('should prefer the account selected in settings', () async { 55 await database.insertAccount( 56 AccountsCompanion.insert(did: 'did:plc:older', handle: 'older.bsky.social', accessToken: 'older-token'), 57 ); 58 await database.insertAccount( 59 AccountsCompanion.insert( 60 did: 'did:plc:selected', 61 handle: 'selected.bsky.social', 62 accessToken: 'selected-token', 63 ), 64 ); 65 await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:older'); 66 67 final active = await database.getActiveAccount(); 68 69 expect(active, isNotNull); 70 expect(active!.did, equals('did:plc:older')); 71 }); 72 73 test('should fall back when the selected active account no longer exists', () async { 74 await database.insertAccount( 75 AccountsCompanion.insert( 76 did: 'did:plc:available', 77 handle: 'available.bsky.social', 78 accessToken: 'available-token', 79 ), 80 ); 81 await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:missing'); 82 83 final active = await database.getActiveAccount(); 84 85 expect(active, isNotNull); 86 expect(active!.did, equals('did:plc:available')); 87 }); 88 89 test('should return null when no active account exists', () async { 90 final active = await database.getActiveAccount(); 91 expect(active, isNull); 92 }); 93 94 test('should get all accounts', () async { 95 final account1 = AccountsCompanion.insert( 96 did: 'did:plc:abc123', 97 handle: 'user1.bsky.social', 98 accessToken: 'token1', 99 ); 100 final account2 = AccountsCompanion.insert( 101 did: 'did:plc:def456', 102 handle: 'user2.bsky.social', 103 accessToken: 'token2', 104 ); 105 106 await database.insertAccount(account1); 107 await database.insertAccount(account2); 108 109 final accounts = await database.getAllAccounts(); 110 expect(accounts.length, equals(2)); 111 }); 112 113 test('should delete account', () async { 114 final account = AccountsCompanion.insert( 115 did: 'did:plc:abc123', 116 handle: 'user.bsky.social', 117 accessToken: 'access_token', 118 ); 119 120 await database.insertAccount(account); 121 await database.deleteAccount('did:plc:abc123'); 122 123 final retrieved = await database.getAccount('did:plc:abc123'); 124 expect(retrieved, isNull); 125 }); 126 127 test('should delete all accounts', () async { 128 final account1 = AccountsCompanion.insert( 129 did: 'did:plc:abc123', 130 handle: 'user1.bsky.social', 131 accessToken: 'token1', 132 ); 133 final account2 = AccountsCompanion.insert( 134 did: 'did:plc:def456', 135 handle: 'user2.bsky.social', 136 accessToken: 'token2', 137 ); 138 139 await database.insertAccount(account1); 140 await database.insertAccount(account2); 141 await database.deleteAllAccounts(); 142 143 final accounts = await database.getAllAccounts(); 144 expect(accounts, isEmpty); 145 }); 146 147 test('should update account tokens', () async { 148 final account = AccountsCompanion.insert( 149 did: 'did:plc:abc123', 150 handle: 'user.bsky.social', 151 accessToken: 'old_token', 152 ); 153 154 await database.insertAccount(account); 155 final updated = await database.updateAccountTokens( 156 'did:plc:abc123', 157 accessToken: 'new_token', 158 refreshToken: 'new_refresh', 159 dpopNonce: 'nonce-1', 160 ); 161 162 expect(updated, isTrue); 163 164 final retrieved = await database.getAccount('did:plc:abc123'); 165 expect(retrieved!.accessToken, equals('new_token')); 166 expect(retrieved.refreshToken, equals('new_refresh')); 167 expect(retrieved.dpopNonce, equals('nonce-1')); 168 }); 169 170 test('should persist oauth service separately from pds service', () async { 171 final account = AccountsCompanion.insert( 172 did: 'did:plc:oauth123', 173 handle: 'oauth-user.bsky.social', 174 accessToken: 'access-token', 175 service: const Value('porcini.us-east.host.bsky.network'), 176 oauthService: const Value('bsky.social'), 177 ); 178 179 await database.insertAccount(account); 180 final retrieved = await database.getAccount('did:plc:oauth123'); 181 182 expect(retrieved, isNotNull); 183 expect(retrieved!.service, equals('porcini.us-east.host.bsky.network')); 184 expect(retrieved.oauthService, equals('bsky.social')); 185 }); 186 187 test('should persist oauth client id for oauth-backed accounts', () async { 188 final account = AccountsCompanion.insert( 189 did: 'did:plc:oauthclient123', 190 handle: 'oauth-client-user.bsky.social', 191 accessToken: 'access-token', 192 oauthClientId: const Value('https://lazurite.stormlightlabs.org/client-metadata.json'), 193 ); 194 195 await database.insertAccount(account); 196 final retrieved = await database.getAccount('did:plc:oauthclient123'); 197 198 expect(retrieved, isNotNull); 199 expect(retrieved!.oauthClientId, equals('https://lazurite.stormlightlabs.org/client-metadata.json')); 200 }); 201 }); 202 203 group('Cache operations', () { 204 test('should cache a profile payload', () async { 205 await database.cacheProfile( 206 did: 'did:plc:abc123', 207 handle: 'user.bsky.social', 208 payload: '{"did":"did:plc:abc123"}', 209 ); 210 211 final cached = await database.select(database.cachedProfiles).getSingle(); 212 expect(cached.did, equals('did:plc:abc123')); 213 expect(cached.handle, equals('user.bsky.social')); 214 }); 215 216 test('should cache a post payload', () async { 217 await database.cachePost( 218 uri: 'at://did:plc:abc123/app.bsky.feed.post/123', 219 authorDid: 'did:plc:abc123', 220 payload: '{"uri":"at://did:plc:abc123/app.bsky.feed.post/123"}', 221 ); 222 223 final cached = await database.select(database.cachedPosts).getSingle(); 224 expect(cached.authorDid, equals('did:plc:abc123')); 225 }); 226 227 test('should cache a feed page payload', () async { 228 await database.cacheFeedPage( 229 accountDid: 'did:plc:test', 230 feedKey: 'timeline', 231 payload: '{"cursor":"next","posts":[]}', 232 ); 233 234 final cached = await database.getCachedFeedPage('did:plc:test', 'timeline'); 235 expect(cached, isNotNull); 236 expect(cached!.payload, equals('{"cursor":"next","posts":[]}')); 237 }); 238 239 test('should upsert and prune cached feed posts', () async { 240 final posts = List.generate( 241 3, 242 (index) => CachedFeedPostsCompanion.insert( 243 accountDid: 'did:plc:test', 244 feedKey: 'timeline', 245 postUri: 'at://did:plc:test/app.bsky.feed.post/$index', 246 postJson: '{"uri":"$index"}', 247 sortOrder: 3 - index, 248 ), 249 ); 250 await database.upsertCachedFeedPosts(accountDid: 'did:plc:test', feedKey: 'timeline', posts: posts); 251 await database.pruneCachedFeedPosts(accountDid: 'did:plc:test', feedKey: 'timeline', maxCount: 2); 252 253 final cached = await database.getCachedFeedPosts('did:plc:test', 'timeline'); 254 expect(cached.length, 2); 255 expect(cached.first.postUri, 'at://did:plc:test/app.bsky.feed.post/0'); 256 expect(cached.last.postUri, 'at://did:plc:test/app.bsky.feed.post/1'); 257 }); 258 259 test('should cache and prune thread roots by recency', () async { 260 await database.cacheThreadRoot( 261 accountDid: 'did:plc:test', 262 rootUri: 'at://did:plc:test/app.bsky.feed.post/old', 263 payload: '{"uri":"old"}', 264 fetchedAt: DateTime.utc(2026, 5, 1, 10), 265 ); 266 await database.cacheThreadRoot( 267 accountDid: 'did:plc:test', 268 rootUri: 'at://did:plc:test/app.bsky.feed.post/new', 269 payload: '{"uri":"new"}', 270 fetchedAt: DateTime.utc(2026, 5, 1, 11), 271 ); 272 273 await database.pruneCachedThreadRoots('did:plc:test', 1); 274 275 final newest = await database.getCachedThreadRoot('did:plc:test', 'at://did:plc:test/app.bsky.feed.post/new'); 276 final oldest = await database.getCachedThreadRoot('did:plc:test', 'at://did:plc:test/app.bsky.feed.post/old'); 277 expect(newest, isNotNull); 278 expect(oldest, isNull); 279 }); 280 281 test('clearLocalCaches removes cache tables while preserving user data', () async { 282 await database.insertAccount( 283 AccountsCompanion.insert(did: 'did:plc:user', handle: 'user.bsky.social', accessToken: 'token'), 284 ); 285 await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:user'); 286 await database.setSetting('theme', 'dark'); 287 await database.setSetting('moderation_preferences::did:plc:user', '[]'); 288 await database.cacheProfile(did: 'did:plc:user', handle: 'user.bsky.social', payload: '{}'); 289 await database.cachePost( 290 uri: 'at://did:plc:user/app.bsky.feed.post/1', 291 authorDid: 'did:plc:user', 292 payload: '{}', 293 ); 294 await database.cacheFeedPage(accountDid: 'did:plc:user', feedKey: 'timeline', payload: '{}'); 295 await database.upsertCachedFeedPosts( 296 accountDid: 'did:plc:user', 297 feedKey: 'timeline', 298 posts: [ 299 CachedFeedPostsCompanion.insert( 300 accountDid: 'did:plc:user', 301 feedKey: 'timeline', 302 postUri: 'at://did:plc:user/app.bsky.feed.post/1', 303 postJson: '{}', 304 sortOrder: 1, 305 ), 306 ], 307 ); 308 await database.cacheThreadRoot( 309 accountDid: 'did:plc:user', 310 rootUri: 'at://did:plc:user/app.bsky.feed.post/1', 311 payload: '{}', 312 ); 313 await database.upsertLabelerCache('did:plc:labeler', '{}'); 314 await database.saveDraft(DraftsCompanion.insert(accountDid: 'did:plc:user', content: 'draft')); 315 await database.savePost( 316 SavedPostsCompanion.insert( 317 accountDid: 'did:plc:user', 318 postUri: 'at://did:plc:user/app.bsky.feed.post/saved', 319 postJson: '{}', 320 ), 321 ); 322 323 await database.clearLocalCaches(); 324 325 expect(await database.select(database.cachedProfiles).get(), isEmpty); 326 expect(await database.select(database.cachedPosts).get(), isEmpty); 327 expect(await database.select(database.cachedFeedPages).get(), isEmpty); 328 expect(await database.select(database.cachedFeedPosts).get(), isEmpty); 329 expect(await database.select(database.cachedThreadRoots).get(), isEmpty); 330 expect(await database.select(database.labelerCache).get(), isEmpty); 331 expect(await database.getSetting('moderation_preferences::did:plc:user'), isNull); 332 333 expect(await database.getAccount('did:plc:user'), isNotNull); 334 expect(await database.getSetting(AppDatabase.activeAccountDidSettingKey), 'did:plc:user'); 335 expect(await database.getSetting('theme'), 'dark'); 336 expect(await database.getDrafts('did:plc:user'), hasLength(1)); 337 expect(await database.getSavedPosts('did:plc:user'), hasLength(1)); 338 }); 339 }); 340 341 group('Notification delivery operations', () { 342 test('should update existing delivery metadata on duplicate insert', () async { 343 final firstInsert = await database.recordNotificationDelivery( 344 accountDid: 'did:plc:test', 345 notificationUri: 'at://did:plc:test/app.bsky.feed.post/1', 346 notificationCid: 'cid-1', 347 reason: 'like', 348 indexedAt: DateTime.utc(2026, 5, 1, 9, 0), 349 source: 'poll', 350 ); 351 352 final secondInsert = await database.recordNotificationDelivery( 353 accountDid: 'did:plc:test', 354 notificationUri: 'at://did:plc:test/app.bsky.feed.post/1', 355 notificationCid: 'cid-2', 356 reason: 'repost', 357 indexedAt: DateTime.utc(2026, 5, 1, 10, 0), 358 source: 'push', 359 ); 360 361 final delivery = await database.getNotificationDelivery( 362 'did:plc:test', 363 'at://did:plc:test/app.bsky.feed.post/1', 364 ); 365 366 expect(firstInsert, isTrue); 367 expect(secondInsert, isFalse); 368 expect(await database.countNotificationDeliveries('did:plc:test'), 1); 369 expect(delivery, isNotNull); 370 expect(delivery!.notificationCid, 'cid-2'); 371 expect(delivery.reason, 'repost'); 372 expect(delivery.indexedAt.toUtc(), DateTime.utc(2026, 5, 1, 10, 0)); 373 expect(delivery.source, 'push'); 374 }); 375 }); 376 377 group('Settings operations', () { 378 test('should seed default typeahead provider on database creation', () async { 379 final value = await database.getSetting('typeahead_provider'); 380 expect(value, equals('bluesky')); 381 }); 382 383 test('should seed default appview provider on database creation', () async { 384 final value = await database.getSetting('appview_provider'); 385 expect(value, equals('bluesky')); 386 }); 387 388 test('should set and get setting', () async { 389 await database.setSetting('theme', 'dark'); 390 final value = await database.getSetting('theme'); 391 392 expect(value, equals('dark')); 393 }); 394 395 test('should return null for non-existent setting', () async { 396 final value = await database.getSetting('nonexistent'); 397 expect(value, isNull); 398 }); 399 400 test('should update existing setting', () async { 401 await database.setSetting('theme', 'light'); 402 await database.setSetting('theme', 'dark'); 403 final value = await database.getSetting('theme'); 404 405 expect(value, equals('dark')); 406 }); 407 408 test('should delete setting', () async { 409 await database.setSetting('theme', 'dark'); 410 await database.deleteSetting('theme'); 411 final value = await database.getSetting('theme'); 412 413 expect(value, isNull); 414 }); 415 416 test('should persist thread auto-collapse depth setting', () async { 417 await database.setSetting('thread_auto_collapse_depth', '3'); 418 final value = await database.getSetting('thread_auto_collapse_depth'); 419 420 expect(value, equals('3')); 421 }); 422 }); 423 }); 424}