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:bloc_test/bloc_test.dart';
7import 'package:flutter_test/flutter_test.dart';
8import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart';
9
10class FakeDevToolsRepository implements DevToolsRepository {
11 FakeDevToolsRepository({
12 this.resolveHandleHandler,
13 this.describeRepoHandler,
14 this.listRecordsHandler,
15 this.getRecordHandler,
16 });
17
18 Future<IdentityResolveHandleOutput> Function({required String handle})? resolveHandleHandler;
19 Future<RepoDescribeRepoOutput> Function({required String repo})? describeRepoHandler;
20 Future<RepoListRecordsOutput> Function({
21 required String repo,
22 required String collection,
23 int? limit,
24 String? cursor,
25 bool? reverse,
26 })?
27 listRecordsHandler;
28 Future<RepoGetRecordOutput> Function({required String repo, required String collection, required String rkey})?
29 getRecordHandler;
30
31 @override
32 Future<RepoDescribeRepoOutput> describeRepo({required String repo}) {
33 return describeRepoHandler!.call(repo: repo);
34 }
35
36 @override
37 Future<RepoGetRecordOutput> getRecord({required String repo, required String collection, required String rkey}) {
38 return getRecordHandler!.call(repo: repo, collection: collection, rkey: rkey);
39 }
40
41 @override
42 Future<RepoListRecordsOutput> listRecords({
43 required String repo,
44 required String collection,
45 int? limit,
46 String? cursor,
47 bool? reverse,
48 }) {
49 return listRecordsHandler!.call(repo: repo, collection: collection, limit: limit, cursor: cursor, reverse: reverse);
50 }
51
52 @override
53 Future<IdentityResolveHandleOutput> resolveHandle({required String handle}) {
54 return resolveHandleHandler!.call(handle: handle);
55 }
56}
57
58void main() {
59 group('DevToolsCubit', () {
60 test('initial state is DevToolsState with initial status', () {
61 final cubit = DevToolsCubit(repository: FakeDevToolsRepository());
62 expect(cubit.state, const DevToolsState());
63 });
64
65 blocTest<DevToolsCubit, DevToolsState>(
66 'clearInput resets to the initial state',
67 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
68 seed: () => const DevToolsState(status: DevToolsStatus.repoLoaded, did: 'did:plc:test'),
69 act: (cubit) => cubit.clearInput(),
70 expect: () => [const DevToolsState()],
71 );
72
73 blocTest<DevToolsCubit, DevToolsState>(
74 'resolve handle loads repo and progressive collection counts',
75 build: () {
76 final repository = FakeDevToolsRepository(
77 resolveHandleHandler: ({required String handle}) async =>
78 const IdentityResolveHandleOutput(did: 'did:plc:alice'),
79 describeRepoHandler: ({required String repo}) async => const RepoDescribeRepoOutput(
80 handle: 'alice.bsky.social',
81 did: 'did:plc:alice',
82 didDoc: {},
83 collections: ['app.bsky.feed.post'],
84 handleIsCorrect: true,
85 ),
86 listRecordsHandler:
87 ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async {
88 expect(repo, 'did:plc:alice');
89 expect(collection, 'app.bsky.feed.post');
90 return RepoListRecordsOutput(
91 cursor: cursor == null ? 'next' : null,
92 records: [
93 RepoListRecordsRecord(
94 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/${cursor == null ? '1' : '2'}'),
95 cid: 'cid-${cursor ?? 'first'}',
96 value: {'text': 'post ${cursor ?? 'first'}'},
97 ),
98 ],
99 );
100 },
101 );
102
103 return DevToolsCubit(repository: repository);
104 },
105 act: (cubit) => cubit.resolve('alice.bsky.social'),
106 wait: const Duration(milliseconds: 10),
107 expect: () => [
108 const DevToolsState(status: DevToolsStatus.loading),
109 isA<DevToolsState>()
110 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
111 .having((state) => state.did, 'did', 'did:plc:alice')
112 .having((state) => state.repoHandle, 'repoHandle', 'alice.bsky.social')
113 .having((state) => state.collections.first.recordCount, 'initial count', isNull)
114 .having((state) => state.isCollectionCountsLoading, 'count loading', isTrue),
115 isA<DevToolsState>()
116 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
117 .having((state) => state.collections.first.recordCount, 'resolved count', 2)
118 .having((state) => state.totalRepoRecords, 'total repo records', 2)
119 .having((state) => state.isCollectionCountsLoading, 'count loading', isFalse),
120 ],
121 );
122
123 blocTest<DevToolsCubit, DevToolsState>(
124 'resolve AT-URI loads collection and full record JSON',
125 build: () {
126 final repository = FakeDevToolsRepository(
127 resolveHandleHandler: ({required String handle}) async =>
128 const IdentityResolveHandleOutput(did: 'did:plc:alice'),
129 describeRepoHandler: ({required String repo}) async => const RepoDescribeRepoOutput(
130 handle: 'alice.bsky.social',
131 did: 'did:plc:alice',
132 didDoc: {},
133 collections: ['app.bsky.feed.post'],
134 handleIsCorrect: true,
135 ),
136 listRecordsHandler:
137 ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async {
138 if (limit == 100) {
139 return const RepoListRecordsOutput(
140 records: [
141 RepoListRecordsRecord(
142 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'),
143 cid: 'cid-count',
144 value: {'text': 'count me'},
145 ),
146 ],
147 );
148 }
149
150 return const RepoListRecordsOutput(
151 records: [
152 RepoListRecordsRecord(
153 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'),
154 cid: 'cid-list',
155 value: {'text': 'summary'},
156 ),
157 ],
158 );
159 },
160 getRecordHandler: ({required String repo, required String collection, required String rkey}) async {
161 expect(repo, 'did:plc:alice');
162 expect(collection, 'app.bsky.feed.post');
163 expect(rkey, '3kz');
164 return const RepoGetRecordOutput(
165 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'),
166 cid: 'cid-full',
167 value: {
168 'text': 'full record',
169 'nested': {'ok': true},
170 },
171 );
172 },
173 );
174
175 return DevToolsCubit(repository: repository);
176 },
177 act: (cubit) => cubit.resolve('at://alice.bsky.social/app.bsky.feed.post/3kz'),
178 wait: const Duration(milliseconds: 10),
179 expect: () => [
180 const DevToolsState(status: DevToolsStatus.loading),
181 isA<DevToolsState>()
182 .having((state) => state.status, 'status', DevToolsStatus.recordLoaded)
183 .having((state) => state.did, 'did', 'did:plc:alice')
184 .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post')
185 .having((state) => state.records?.length, 'list records', 1)
186 .having((state) => state.selectedRecord?.cid, 'full cid', 'cid-full')
187 .having((state) => state.selectedRecord?.value['nested'], 'nested JSON', {'ok': true}),
188 isA<DevToolsState>()
189 .having((state) => state.collections.first.recordCount, 'collection count', 1)
190 .having((state) => state.totalRepoRecords, 'total repo records', 1),
191 ],
192 );
193
194 blocTest<DevToolsCubit, DevToolsState>(
195 'loadRecord replaces list preview with getRecord response',
196 build: () {
197 final repository = FakeDevToolsRepository(
198 getRecordHandler: ({required String repo, required String collection, required String rkey}) async {
199 return const RepoGetRecordOutput(
200 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
201 cid: 'cid123',
202 value: {
203 'text': 'Expanded',
204 'reply': {'root': 'abc'},
205 },
206 );
207 },
208 );
209
210 return DevToolsCubit(repository: repository);
211 },
212 seed: () => const DevToolsState(
213 status: DevToolsStatus.collectionLoaded,
214 did: 'did:plc:test',
215 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
216 selectedCollection: 'app.bsky.feed.post',
217 records: [
218 RepoListRecordsRecord(
219 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
220 cid: 'cid-list',
221 value: {'text': 'Summary'},
222 ),
223 ],
224 ),
225 act: (cubit) => cubit.loadRecord(
226 const RepoListRecordsRecord(
227 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
228 cid: 'cid-list',
229 value: {'text': 'Summary'},
230 ),
231 ),
232 expect: () => [
233 isA<DevToolsState>()
234 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded)
235 .having((state) => state.isRecordLoading, 'isRecordLoading', isTrue),
236 isA<DevToolsState>()
237 .having((state) => state.status, 'status', DevToolsStatus.recordLoaded)
238 .having((state) => state.isRecordLoading, 'isRecordLoading', isFalse)
239 .having((state) => state.selectedRecord?.cid, 'cid', 'cid123')
240 .having((state) => state.selectedRecord?.value['reply'], 'expanded value', {'root': 'abc'}),
241 ],
242 );
243
244 blocTest<DevToolsCubit, DevToolsState>(
245 'loadCollection keeps repo view active while records load',
246 build: () {
247 final repository = FakeDevToolsRepository(
248 listRecordsHandler:
249 ({required String repo, required String collection, int? limit, String? cursor, bool? reverse}) async {
250 return const RepoListRecordsOutput(
251 records: [
252 RepoListRecordsRecord(
253 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
254 cid: 'cid123',
255 value: {'text': 'Summary'},
256 ),
257 ],
258 );
259 },
260 );
261
262 return DevToolsCubit(repository: repository);
263 },
264 seed: () => const DevToolsState(
265 status: DevToolsStatus.repoLoaded,
266 did: 'did:plc:test',
267 repoHandle: 'test.bsky.social',
268 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
269 ),
270 act: (cubit) => cubit.loadCollection('app.bsky.feed.post'),
271 expect: () => [
272 isA<DevToolsState>()
273 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
274 .having((state) => state.isCollectionLoading, 'isCollectionLoading', isTrue),
275 isA<DevToolsState>()
276 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded)
277 .having((state) => state.isCollectionLoading, 'isCollectionLoading', isFalse)
278 .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post')
279 .having((state) => state.records?.length, 'records', 1),
280 ],
281 );
282
283 blocTest<DevToolsCubit, DevToolsState>(
284 'invalid AT-URI surfaces a clear error',
285 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
286 act: (cubit) => cubit.resolve('at://bad uri'),
287 expect: () => [
288 const DevToolsState(status: DevToolsStatus.loading),
289 isA<DevToolsState>()
290 .having((state) => state.status, 'status', DevToolsStatus.error)
291 .having((state) => state.errorMessage, 'error', 'Invalid AT-URI'),
292 ],
293 );
294
295 blocTest<DevToolsCubit, DevToolsState>(
296 'goBackToCollection clears selectedRecord',
297 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
298 seed: () => const DevToolsState(
299 status: DevToolsStatus.recordLoaded,
300 did: 'did:plc:test',
301 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
302 selectedCollection: 'app.bsky.feed.post',
303 records: [
304 RepoListRecordsRecord(
305 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
306 cid: 'cid',
307 value: {'text': 'Summary'},
308 ),
309 ],
310 selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}),
311 ),
312 act: (cubit) => cubit.goBackToCollection(),
313 expect: () => [
314 isA<DevToolsState>()
315 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded)
316 .having((state) => state.selectedRecord, 'selectedRecord', isNull)
317 .having((state) => state.records?.length, 'records length', 1),
318 ],
319 );
320
321 blocTest<DevToolsCubit, DevToolsState>(
322 'goBackToRepo clears collection and record state',
323 build: () => DevToolsCubit(repository: FakeDevToolsRepository()),
324 seed: () => const DevToolsState(
325 status: DevToolsStatus.recordLoaded,
326 did: 'did:plc:test',
327 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
328 selectedCollection: 'app.bsky.feed.post',
329 records: [
330 RepoListRecordsRecord(
331 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
332 cid: 'cid',
333 value: {'text': 'Summary'},
334 ),
335 ],
336 selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}),
337 ),
338 act: (cubit) => cubit.goBackToRepo(),
339 expect: () => [
340 isA<DevToolsState>()
341 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded)
342 .having((state) => state.selectedCollection, 'selectedCollection', isNull)
343 .having((state) => state.records, 'records', isNull)
344 .having((state) => state.selectedRecord, 'selectedRecord', isNull),
345 ],
346 );
347 });
348}