mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
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}