mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:atproto/com_atproto_identity_resolvehandle.dart';
2import 'package:atproto/com_atproto_repo_describerepo.dart';
3import 'package:atproto/com_atproto_repo_getrecord.dart';
4import 'package:atproto/com_atproto_repo_listrecords.dart';
5import 'package:atproto_core/atproto_core.dart';
6import 'package:bluesky/app_bsky_actor_defs.dart';
7import 'package:bloc_test/bloc_test.dart';
8import 'package:flutter_test/flutter_test.dart';
9import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart';
10
11class FakeDevToolsRepository implements DevToolsRepository {
12 FakeDevToolsRepository({
13 this.resolveHandleHandler,
14 this.describeRepoHandler,
15 this.searchActorsTypeaheadHandler,
16 this.listRecordsHandler,
17 this.getRecordHandler,
18 });
19
20 Future<IdentityResolveHandleOutput> Function({required String handle})? resolveHandleHandler;
21 Future<RepoDescribeRepoOutput> Function({required String repo, String? serviceHost})? describeRepoHandler;
22 Future<List<ProfileViewBasic>> Function({required String query, int limit})? searchActorsTypeaheadHandler;
23 Future<RepoListRecordsOutput> Function({
24 required String repo,
25 required String collection,
26 int? limit,
27 String? cursor,
28 bool? reverse,
29 String? serviceHost,
30 })?
31 listRecordsHandler;
32 Future<RepoGetRecordOutput> Function({
33 required String repo,
34 required String collection,
35 required String rkey,
36 String? serviceHost,
37 })?
38 getRecordHandler;
39
40 @override
41 Future<RepoDescribeRepoOutput> describeRepo({required String repo, String? serviceHost}) {
42 return describeRepoHandler!.call(repo: repo, serviceHost: serviceHost);
43 }
44
45 @override
46 Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 8}) async {
47 final handler = searchActorsTypeaheadHandler;
48 if (handler == null) {
49 return const [];
50 }
51 return handler(query: query, limit: limit);
52 }
53
54 @override
55 Future<RepoGetRecordOutput> getRecord({
56 required String repo,
57 required String collection,
58 required String rkey,
59 String? serviceHost,
60 }) {
61 return getRecordHandler!.call(repo: repo, collection: collection, rkey: rkey, serviceHost: serviceHost);
62 }
63
64 @override
65 Future<RepoListRecordsOutput> listRecords({
66 required String repo,
67 required String collection,
68 int? limit,
69 String? cursor,
70 bool? reverse,
71 String? serviceHost,
72 }) {
73 return listRecordsHandler!.call(
74 repo: repo,
75 collection: collection,
76 limit: limit,
77 cursor: cursor,
78 reverse: reverse,
79 serviceHost: serviceHost,
80 );
81 }
82
83 @override
84 Future<IdentityResolveHandleOutput> resolveHandle({required String handle}) {
85 return resolveHandleHandler!.call(handle: handle);
86 }
87}
88
89void main() {
90 group('DevToolsCubit', () {
91 test('initial state is DevToolsState with initial status', () {
92 final cubit = DevToolsCubit(repository: FakeDevToolsRepository());
93 expect(cubit.state, const DevToolsState());
94 });
95
96 blocTest<DevToolsCubit, DevToolsState>(
97 'clearInput resets to the initial state',
98 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
99 seed: () => const DevToolsState(status: DevToolsStatus.repoLoaded, did: 'did:plc:test'),
100 act: (cubit) => cubit.clearInput(),
101 expect: () => [const DevToolsState()],
102 );
103
104 blocTest<DevToolsCubit, DevToolsState>(
105 'queryTypeahead loads suggestions for @handle input',
106 build: () {
107 final repository = FakeDevToolsRepository(
108 searchActorsTypeaheadHandler: ({required String query, int limit = 8}) async {
109 expect(query, 'alice');
110 expect(limit, 8);
111 return const [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social')];
112 },
113 );
114 return DevToolsCubit(repository: repository);
115 },
116 act: (cubit) => cubit.queryTypeahead('@alice'),
117 expect: () => [
118 isA<DevToolsState>()
119 .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isTrue)
120 .having((state) => state.typeaheadActors, 'typeaheadActors', isEmpty),
121 isA<DevToolsState>()
122 .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isFalse)
123 .having((state) => state.typeaheadActors.length, 'typeaheadActors.length', 1)
124 .having((state) => state.typeaheadActors.first.handle, 'first actor handle', 'alice.bsky.social'),
125 ],
126 );
127
128 blocTest<DevToolsCubit, DevToolsState>(
129 'queryTypeahead clears suggestions when input is not an @handle',
130 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
131 seed: () => const DevToolsState(
132 typeaheadActors: [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social')],
133 isTypeaheadLoading: true,
134 ),
135 act: (cubit) => cubit.queryTypeahead('alice'),
136 expect: () => [
137 isA<DevToolsState>()
138 .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isFalse)
139 .having((state) => state.typeaheadActors, 'typeaheadActors', isEmpty),
140 ],
141 );
142
143 blocTest<DevToolsCubit, DevToolsState>(
144 'resolve handle loads repo and progressive collection counts',
145 build: () {
146 var describeRepoCalls = 0;
147 final repository = FakeDevToolsRepository(
148 resolveHandleHandler: ({required String handle}) async {
149 expect(handle, 'alice.bsky.social');
150 return const IdentityResolveHandleOutput(did: 'did:plc:alice');
151 },
152 describeRepoHandler: ({required String repo, String? serviceHost}) async {
153 expect(repo, 'did:plc:alice');
154 if (describeRepoCalls == 0) {
155 expect(serviceHost, isNull);
156 } else {
157 expect(serviceHost, 'alice.host');
158 }
159 describeRepoCalls++;
160 return const RepoDescribeRepoOutput(
161 handle: 'alice.bsky.social',
162 did: 'did:plc:alice',
163 didDoc: {
164 'service': [
165 {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://alice.host'},
166 ],
167 },
168 collections: ['app.bsky.feed.post'],
169 handleIsCorrect: true,
170 );
171 },
172 listRecordsHandler:
173 ({
174 required String repo,
175 required String collection,
176 int? limit,
177 String? cursor,
178 bool? reverse,
179 String? serviceHost,
180 }) async {
181 expect(repo, 'did:plc:alice');
182 expect(collection, 'app.bsky.feed.post');
183 expect(serviceHost, 'alice.host');
184 return RepoListRecordsOutput(
185 cursor: cursor == null ? 'next' : null,
186 records: [
187 RepoListRecordsRecord(
188 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/${cursor == null ? '1' : '2'}'),
189 cid: 'cid-${cursor ?? 'first'}',
190 value: {'text': 'post ${cursor ?? 'first'}'},
191 ),
192 ],
193 );
194 },
195 );
196
197 return DevToolsCubit(repository: repository);
198 },
199 act: (cubit) => cubit.resolve('@alice.bsky.social'),
200 wait: const Duration(milliseconds: 10),
201 expect: () => [
202 const DevToolsState(status: DevToolsStatus.loading),
203 isA<DevToolsState>()
204 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
205 .having((state) => state.did, 'did', 'did:plc:alice')
206 .having((state) => state.repoHandle, 'repoHandle', 'alice.bsky.social')
207 .having((state) => state.collections.first.recordCount, 'initial count', isNull)
208 .having((state) => state.isCollectionCountsLoading, 'count loading', isTrue),
209 isA<DevToolsState>()
210 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
211 .having((state) => state.collections.first.recordCount, 'resolved count', 2)
212 .having((state) => state.totalRepoRecords, 'total repo records', 2)
213 .having((state) => state.isCollectionCountsLoading, 'count loading', isFalse),
214 ],
215 );
216
217 blocTest<DevToolsCubit, DevToolsState>(
218 'resolve AT-URI loads collection and full record JSON',
219 build: () {
220 var describeRepoCalls = 0;
221 final repository = FakeDevToolsRepository(
222 resolveHandleHandler: ({required String handle}) async =>
223 const IdentityResolveHandleOutput(did: 'did:plc:alice'),
224 describeRepoHandler: ({required String repo, String? serviceHost}) async {
225 expect(repo, 'did:plc:alice');
226 if (describeRepoCalls == 0) {
227 expect(serviceHost, isNull);
228 } else {
229 expect(serviceHost, 'alice.host');
230 }
231 describeRepoCalls++;
232 return const RepoDescribeRepoOutput(
233 handle: 'alice.bsky.social',
234 did: 'did:plc:alice',
235 didDoc: {
236 'service': [
237 {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://alice.host'},
238 ],
239 },
240 collections: ['app.bsky.feed.post'],
241 handleIsCorrect: true,
242 );
243 },
244 listRecordsHandler:
245 ({
246 required String repo,
247 required String collection,
248 int? limit,
249 String? cursor,
250 bool? reverse,
251 String? serviceHost,
252 }) async {
253 expect(serviceHost, 'alice.host');
254 if (limit == 100) {
255 return const RepoListRecordsOutput(
256 records: [
257 RepoListRecordsRecord(
258 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'),
259 cid: 'cid-count',
260 value: {'text': 'count me'},
261 ),
262 ],
263 );
264 }
265
266 return const RepoListRecordsOutput(
267 records: [
268 RepoListRecordsRecord(
269 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'),
270 cid: 'cid-list',
271 value: {'text': 'summary'},
272 ),
273 ],
274 );
275 },
276 getRecordHandler:
277 ({required String repo, required String collection, required String rkey, String? serviceHost}) async {
278 expect(repo, 'did:plc:alice');
279 expect(collection, 'app.bsky.feed.post');
280 expect(rkey, '3kz');
281 expect(serviceHost, 'alice.host');
282 return const RepoGetRecordOutput(
283 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'),
284 cid: 'cid-full',
285 value: {
286 'text': 'full record',
287 'nested': {'ok': true},
288 },
289 );
290 },
291 );
292
293 return DevToolsCubit(repository: repository);
294 },
295 act: (cubit) => cubit.resolve('at://alice.bsky.social/app.bsky.feed.post/3kz'),
296 wait: const Duration(milliseconds: 10),
297 expect: () => [
298 const DevToolsState(status: DevToolsStatus.loading),
299 isA<DevToolsState>()
300 .having((state) => state.status, 'status', DevToolsStatus.recordLoaded)
301 .having((state) => state.did, 'did', 'did:plc:alice')
302 .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post')
303 .having((state) => state.records?.length, 'list records', 1)
304 .having((state) => state.selectedRecord?.cid, 'full cid', 'cid-full')
305 .having((state) => state.selectedRecord?.value['nested'], 'nested JSON', {'ok': true}),
306 isA<DevToolsState>()
307 .having((state) => state.collections.first.recordCount, 'collection count', 1)
308 .having((state) => state.totalRepoRecords, 'total repo records', 1),
309 ],
310 );
311
312 blocTest<DevToolsCubit, DevToolsState>(
313 'loadRecord replaces list preview with getRecord response',
314 build: () {
315 final repository = FakeDevToolsRepository(
316 getRecordHandler:
317 ({required String repo, required String collection, required String rkey, String? serviceHost}) async {
318 return const RepoGetRecordOutput(
319 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
320 cid: 'cid123',
321 value: {
322 'text': 'Expanded',
323 'reply': {'root': 'abc'},
324 },
325 );
326 },
327 );
328
329 return DevToolsCubit(repository: repository);
330 },
331 seed: () => const DevToolsState(
332 status: DevToolsStatus.collectionLoaded,
333 did: 'did:plc:test',
334 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
335 selectedCollection: 'app.bsky.feed.post',
336 records: [
337 RepoListRecordsRecord(
338 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
339 cid: 'cid-list',
340 value: {'text': 'Summary'},
341 ),
342 ],
343 ),
344 act: (cubit) => cubit.loadRecord(
345 const RepoListRecordsRecord(
346 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
347 cid: 'cid-list',
348 value: {'text': 'Summary'},
349 ),
350 ),
351 expect: () => [
352 isA<DevToolsState>()
353 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded)
354 .having((state) => state.isRecordLoading, 'isRecordLoading', isTrue),
355 isA<DevToolsState>()
356 .having((state) => state.status, 'status', DevToolsStatus.recordLoaded)
357 .having((state) => state.isRecordLoading, 'isRecordLoading', isFalse)
358 .having((state) => state.selectedRecord?.cid, 'cid', 'cid123')
359 .having((state) => state.selectedRecord?.value['reply'], 'expanded value', {'root': 'abc'}),
360 ],
361 );
362
363 blocTest<DevToolsCubit, DevToolsState>(
364 'loadCollection keeps repo view active while records load',
365 build: () {
366 final repository = FakeDevToolsRepository(
367 listRecordsHandler:
368 ({
369 required String repo,
370 required String collection,
371 int? limit,
372 String? cursor,
373 bool? reverse,
374 String? serviceHost,
375 }) async {
376 return const RepoListRecordsOutput(
377 records: [
378 RepoListRecordsRecord(
379 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
380 cid: 'cid123',
381 value: {'text': 'Summary'},
382 ),
383 ],
384 );
385 },
386 );
387
388 return DevToolsCubit(repository: repository);
389 },
390 seed: () => const DevToolsState(
391 status: DevToolsStatus.repoLoaded,
392 did: 'did:plc:test',
393 repoHandle: 'test.bsky.social',
394 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
395 ),
396 act: (cubit) => cubit.loadCollection('app.bsky.feed.post'),
397 expect: () => [
398 isA<DevToolsState>()
399 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
400 .having((state) => state.isCollectionLoading, 'isCollectionLoading', isTrue),
401 isA<DevToolsState>()
402 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded)
403 .having((state) => state.isCollectionLoading, 'isCollectionLoading', isFalse)
404 .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post')
405 .having((state) => state.records?.length, 'records', 1),
406 ],
407 );
408
409 blocTest<DevToolsCubit, DevToolsState>(
410 'invalid AT-URI surfaces a clear error',
411 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
412 act: (cubit) => cubit.resolve('at://bad uri'),
413 expect: () => [
414 const DevToolsState(status: DevToolsStatus.loading),
415 isA<DevToolsState>()
416 .having((state) => state.status, 'status', DevToolsStatus.error)
417 .having((state) => state.errorMessage, 'error', 'Invalid AT-URI'),
418 ],
419 );
420
421 blocTest<DevToolsCubit, DevToolsState>(
422 'goBackToCollection clears selectedRecord',
423 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
424 seed: () => const DevToolsState(
425 status: DevToolsStatus.recordLoaded,
426 did: 'did:plc:test',
427 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
428 selectedCollection: 'app.bsky.feed.post',
429 records: [
430 RepoListRecordsRecord(
431 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
432 cid: 'cid',
433 value: {'text': 'Summary'},
434 ),
435 ],
436 selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}),
437 ),
438 act: (cubit) => cubit.goBackToCollection(),
439 expect: () => [
440 isA<DevToolsState>()
441 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded)
442 .having((state) => state.selectedRecord, 'selectedRecord', isNull)
443 .having((state) => state.records?.length, 'records length', 1),
444 ],
445 );
446
447 blocTest<DevToolsCubit, DevToolsState>(
448 'goBackToRepo clears collection and record state',
449 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
450 seed: () => const DevToolsState(
451 status: DevToolsStatus.recordLoaded,
452 did: 'did:plc:test',
453 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
454 selectedCollection: 'app.bsky.feed.post',
455 records: [
456 RepoListRecordsRecord(
457 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
458 cid: 'cid',
459 value: {'text': 'Summary'},
460 ),
461 ],
462 selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}),
463 ),
464 act: (cubit) => cubit.goBackToRepo(),
465 expect: () => [
466 isA<DevToolsState>()
467 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
468 .having((state) => state.selectedCollection, 'selectedCollection', isNull)
469 .having((state) => state.records, 'records', isNull)
470 .having((state) => state.selectedRecord, 'selectedRecord', isNull),
471 ],
472 );
473 });
474}