@_nazmiforreal/flutter-ota 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +496 -0
  2. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +743 -0
  3. package/dart-src/packages/cli-tools/lib/src/cli_base.dart +7 -6
  4. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +46 -49
  5. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +44 -23
  6. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +27 -17
  7. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +11 -6
  8. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +39 -22
  9. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -13
  10. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +6 -5
  11. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +15 -9
  12. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +14 -8
  13. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +37 -12
  14. package/dart-src/packages/cli-tools/lib/src/operations.dart +45 -0
  15. package/dart-src/packages/cli-tools/lib/src/pack.dart +9 -0
  16. package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +234 -0
  17. package/dart-src/packages/cli-tools/pubspec.lock +638 -0
  18. package/dart-src/packages/cli-tools/test/backends_test.dart +207 -0
  19. package/dart-src/packages/cli-tools/test/build_pack_test.dart +101 -0
  20. package/dart-src/packages/cli-tools/test/cli_test.dart +442 -0
  21. package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +83 -0
  22. package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +256 -0
  23. package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +347 -0
  24. package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +315 -0
  25. package/dart-src/packages/core/test/rollout_test.dart +128 -0
  26. package/dart-src/packages/core/test/semver_test.dart +52 -0
  27. package/dart-src/packages/core/test/semver_vectors.json +1 -0
  28. package/dart-src/packages/core/test/types_test.dart +129 -0
  29. package/dart-src/packages/core/test/uuid_test.dart +38 -0
  30. package/dart-src/plugins/aws/test/aws_database_test.dart +167 -0
  31. package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +190 -0
  32. package/dart-src/plugins/aws/test/aws_storage_test.dart +73 -0
  33. package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +83 -0
  34. package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +122 -0
  35. package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +106 -0
  36. package/dart-src/plugins/cloudflare/test/d1_database_test.dart +190 -0
  37. package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +256 -0
  38. package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +83 -0
  39. package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +77 -0
  40. package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +166 -0
  41. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +74 -0
  42. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +332 -0
  43. package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +711 -0
  44. package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +294 -0
  45. package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +192 -0
  46. package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +51 -0
  47. package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +1600 -0
  48. package/dart-src/plugins/plugin-core/test/helpers_test.dart +259 -0
  49. package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +139 -0
  50. package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +86 -0
  51. package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +77 -0
  52. package/dart-src/plugins/postgres/test/mock_postgres_client.dart +297 -0
  53. package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +140 -0
  54. package/dart-src/plugins/postgres/test/postgres_database_test.dart +181 -0
  55. package/dart-src/plugins/standalone/test/standalone_test.dart +264 -0
  56. package/dart-src/plugins/supabase/test/mock_supabase_client.dart +654 -0
  57. package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +112 -0
  58. package/dart-src/plugins/supabase/test/supabase_database_test.dart +207 -0
  59. package/dart-src/plugins/supabase/test/supabase_storage_test.dart +175 -0
  60. package/package.json +1 -1
  61. package/scripts/vendor.sh +29 -0
@@ -0,0 +1,711 @@
1
+ import 'dart:convert';
2
+
3
+ import 'package:flutter_patcher_core/flutter_patcher_core.dart';
4
+ import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart';
5
+ import 'package:test/test.dart';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const _defaultBundleFields = <String, Object?>{
12
+ 'fileHash': 'hash',
13
+ 'gitCommitHash': null,
14
+ 'message': null,
15
+ 'enabled': true,
16
+ 'shouldForceUpdate': false,
17
+ 'storageUri': 'storage://my-app/00000000-0000-0000-0000-000000000000/bundle.zip',
18
+ 'fingerprintHash': null,
19
+ };
20
+
21
+ Bundle createBundle(
22
+ String channel,
23
+ String platform,
24
+ String targetAppVersion,
25
+ String id,
26
+ ) {
27
+ return Bundle.fromJson({
28
+ ..._defaultBundleFields,
29
+ 'channel': channel,
30
+ 'platform': platform,
31
+ 'targetAppVersion': targetAppVersion,
32
+ 'id': id,
33
+ });
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Fake blob store
38
+ // ---------------------------------------------------------------------------
39
+
40
+ class _FakeBlobStore {
41
+ final Map<String, String> data = {};
42
+ final List<String> listObjectCalls = [];
43
+ final List<String> loadObjectCalls = [];
44
+ final List<String> invalidationCalls = [];
45
+
46
+ void clear() {
47
+ data.clear();
48
+ listObjectCalls.clear();
49
+ loadObjectCalls.clear();
50
+ invalidationCalls.clear();
51
+ }
52
+
53
+ Future<List<String>> listObjects(String prefix) async {
54
+ listObjectCalls.add(prefix);
55
+ return data.keys.where((k) => k.startsWith(prefix)).toList();
56
+ }
57
+
58
+ Future<T?> loadObject<T>(String key) async {
59
+ loadObjectCalls.add(key);
60
+ final raw = data[key];
61
+ if (raw == null) return null;
62
+ return jsonDecode(raw) as T;
63
+ }
64
+
65
+ Future<void> uploadObject<T>(String key, T value) async {
66
+ data[key] = jsonEncode(value);
67
+ }
68
+
69
+ Future<void> deleteObject(String key) async {
70
+ data.remove(key);
71
+ }
72
+
73
+ Future<void> invalidatePaths(List<String> paths) async {
74
+ invalidationCalls.addAll(paths);
75
+ }
76
+
77
+ bool shouldSkipLoadObjectError(Object error, String key) => false;
78
+ void validateChannel(String channel) {}
79
+ }
80
+
81
+ _BlobOps _createOps(_FakeBlobStore store) => _BlobOps(store);
82
+
83
+ class _BlobOps implements BlobOperations {
84
+ _BlobOps(this._store);
85
+ final _FakeBlobStore _store;
86
+
87
+ @override
88
+ Future<List<String>> listObjects(String prefix) => _store.listObjects(prefix);
89
+ @override
90
+ Future<T?> loadObject<T>(String key) => _store.loadObject<T>(key);
91
+ @override
92
+ Future<void> uploadObject<T>(String key, T data) =>
93
+ _store.uploadObject<T>(key, data);
94
+ @override
95
+ Future<void> deleteObject(String key) => _store.deleteObject(key);
96
+ @override
97
+ bool shouldSkipLoadObjectError(Object error, String key) =>
98
+ _store.shouldSkipLoadObjectError(error, key);
99
+ @override
100
+ void validateChannel(String channel) => _store.validateChannel(channel);
101
+ @override
102
+ Future<void> invalidatePaths(List<String> paths) =>
103
+ _store.invalidatePaths(paths);
104
+ @override
105
+ String get apiBasePath => '/api/check-update';
106
+ }
107
+
108
+ void _seedUpdateManifests(_FakeBlobStore store, List<Bundle> bundles) {
109
+ final bundlesByKey = <String, List<Bundle>>{};
110
+ final targetVersionsByKey = <String, Set<String>>{};
111
+
112
+ for (final bundle in bundles) {
113
+ final target = bundle.targetAppVersion ?? bundle.fingerprintHash;
114
+ if (target == null) continue;
115
+
116
+ final key =
117
+ '${bundle.channel}/${bundle.platform.value}/$target/update.json';
118
+ bundlesByKey.putIfAbsent(key, () => <Bundle>[]).add(bundle);
119
+
120
+ if (bundle.targetAppVersion != null) {
121
+ final tvKey =
122
+ '${bundle.channel}/${bundle.platform.value}/target-app-versions.json';
123
+ targetVersionsByKey
124
+ .putIfAbsent(tvKey, () => <String>{})
125
+ .add(bundle.targetAppVersion!);
126
+ }
127
+ }
128
+
129
+ for (final entry in bundlesByKey.entries) {
130
+ final sorted = entry.value
131
+ ..sort((a, b) => b.id.compareTo(a.id));
132
+ store.data[entry.key] = jsonEncode(sorted.map((b) => b.toJson()).toList());
133
+ }
134
+
135
+ for (final entry in targetVersionsByKey.entries) {
136
+ store.data[entry.key] = jsonEncode(entry.value.toList());
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Tests
142
+ // ---------------------------------------------------------------------------
143
+
144
+ void main() {
145
+ late _FakeBlobStore store;
146
+ late DatabasePlugin plugin;
147
+
148
+ DatabasePlugin createPlugin() {
149
+ return createBlobDatabasePlugin(
150
+ name: 'blobDatabase',
151
+ blobFactory: (_) => _createOps(store),
152
+ )(null as dynamic)();
153
+ }
154
+
155
+ setUp(() {
156
+ store = _FakeBlobStore();
157
+ plugin = createPlugin();
158
+ });
159
+
160
+ group('update checks', () {
161
+ test('uses direct app-version manifests for update checks', () async {
162
+ final latest = createBundle('production', 'ios', '*',
163
+ '00000000-0000-0000-0000-000000000002');
164
+ final previous = createBundle('production', 'ios', '*',
165
+ '00000000-0000-0000-0000-000000000001');
166
+
167
+ _seedUpdateManifests(store, [previous, latest]);
168
+
169
+ final updateInfo = await plugin.getUpdateInfo(
170
+ AppVersionGetBundlesArgs(
171
+ appVersion: '1.0.0',
172
+ bundleId: '00000000-0000-0000-0000-000000000000',
173
+ platform: Platform.ios,
174
+ ),
175
+ );
176
+
177
+ expect(updateInfo, isNotNull);
178
+ expect(updateInfo!.id, equals(latest.id));
179
+ expect(updateInfo.status, equals(UpdateStatus.update));
180
+
181
+ expect(store.listObjectCalls, isEmpty);
182
+ expect(store.loadObjectCalls, [
183
+ 'production/ios/target-app-versions.json',
184
+ 'production/ios/*/update.json',
185
+ ]);
186
+ });
187
+
188
+ test('uses fingerprint manifests directly for update checks', () async {
189
+ final fingerprintBundle = Bundle.fromJson({
190
+ ..._defaultBundleFields,
191
+ 'channel': 'production',
192
+ 'platform': 'ios',
193
+ 'targetAppVersion': null,
194
+ 'fingerprintHash': 'fingerprint-1',
195
+ 'id': '00000000-0000-0000-0000-000000000010',
196
+ });
197
+
198
+ _seedUpdateManifests(store, [fingerprintBundle]);
199
+
200
+ final updateInfo = await plugin.getUpdateInfo(
201
+ FingerprintGetBundlesArgs(
202
+ bundleId: '00000000-0000-0000-0000-000000000000',
203
+ fingerprintHash: 'fingerprint-1',
204
+ platform: Platform.ios,
205
+ ),
206
+ );
207
+
208
+ expect(updateInfo, isNotNull);
209
+ expect(updateInfo!.id, equals(fingerprintBundle.id));
210
+ expect(updateInfo.status, equals(UpdateStatus.update));
211
+
212
+ expect(store.listObjectCalls, isEmpty);
213
+ expect(store.loadObjectCalls, [
214
+ 'production/ios/fingerprint-1/update.json',
215
+ ]);
216
+ });
217
+
218
+ test('respects cohort eligibility', () async {
219
+ final gatedBundle = Bundle.fromJson({
220
+ ...createBundle('production', 'ios', '*',
221
+ '00000000-0000-0000-0000-000000000021')
222
+ .toJson(),
223
+ 'targetCohorts': ['beta'],
224
+ });
225
+ final fallback = createBundle('production', 'ios', '*',
226
+ '00000000-0000-0000-0000-000000000020');
227
+ final fallbackWithCohort = Bundle.fromJson({
228
+ ...fallback.toJson(),
229
+ 'targetCohorts': ['stable'],
230
+ });
231
+
232
+ _seedUpdateManifests(store, [fallbackWithCohort, gatedBundle]);
233
+
234
+ final updateInfo = await plugin.getUpdateInfo(
235
+ AppVersionGetBundlesArgs(
236
+ appVersion: '1.0.0',
237
+ bundleId: '00000000-0000-0000-0000-000000000000',
238
+ cohort: 'stable',
239
+ platform: Platform.ios,
240
+ ),
241
+ );
242
+
243
+ expect(updateInfo, isNotNull);
244
+ expect(updateInfo!.id, equals(fallbackWithCohort.id));
245
+ expect(updateInfo.status, equals(UpdateStatus.update));
246
+ });
247
+
248
+ test('returns null when no candidates and bundleId = nil', () async {
249
+ final updateInfo = await plugin.getUpdateInfo(
250
+ AppVersionGetBundlesArgs(
251
+ appVersion: '1.0.0',
252
+ bundleId: '00000000-0000-0000-0000-000000000040',
253
+ minBundleId: '00000000-0000-0000-0000-000000000040',
254
+ platform: Platform.ios,
255
+ ),
256
+ );
257
+ expect(updateInfo, isNull);
258
+ });
259
+ });
260
+
261
+ group('append and commit', () {
262
+ test('appends a new bundle and commits to blob store', () async {
263
+ final bundle = createBundle('production', 'ios', '1.0.0',
264
+ '00000000-0000-0000-0000-000000000001');
265
+
266
+ await plugin.appendBundle(bundle);
267
+ await plugin.commitBundle();
268
+
269
+ final bundleKey = 'production/ios/1.0.0/update.json';
270
+ expect(store.data.containsKey(bundleKey), isTrue);
271
+
272
+ final stored = jsonDecode(store.data[bundleKey]!) as List;
273
+ expect(stored.length, equals(1));
274
+
275
+ final fetched = await plugin.getBundleById(
276
+ '00000000-0000-0000-0000-000000000001');
277
+ expect(fetched, isNotNull);
278
+ expect(fetched!.id, equals(bundle.id));
279
+ });
280
+
281
+ test('does not write to store until commitBundle', () async {
282
+ final bundle = createBundle('production', 'ios', '1.0.0',
283
+ '00000000-0000-0000-0000-000000000010');
284
+
285
+ expect(store.data, isEmpty);
286
+
287
+ await plugin.appendBundle(bundle);
288
+ expect(store.data, isEmpty);
289
+
290
+ await plugin.commitBundle();
291
+ expect(store.data.containsKey('production/ios/1.0.0/update.json'), isTrue);
292
+ });
293
+
294
+ test('appends multiple bundles to correct keys', () async {
295
+ final b1 = createBundle('production', 'ios', '1.0.0', 'multi-1');
296
+ final b2 =
297
+ createBundle('production', 'android', '2.0.0', 'multi-2');
298
+
299
+ await plugin.appendBundle(b1);
300
+ await plugin.appendBundle(b2);
301
+ await plugin.commitBundle();
302
+
303
+ expect(store.data.containsKey('production/ios/1.0.0/update.json'), isTrue);
304
+ expect(store.data.containsKey('production/android/2.0.0/update.json'),
305
+ isTrue);
306
+ });
307
+ });
308
+
309
+ group('update and delete', () {
310
+ test('updates an existing bundle', () async {
311
+ final bundle = createBundle('production', 'android', '2.0.0',
312
+ '00000000-0000-0000-0000-000000000002');
313
+ final bundleKey = 'production/android/2.0.0/update.json';
314
+ store.data[bundleKey] = jsonEncode([bundle.toJson()]);
315
+
316
+ await plugin.getBundles(
317
+ const DatabaseBundleQueryOptions(limit: 20));
318
+ await plugin.updateBundle(
319
+ '00000000-0000-0000-0000-000000000002', {'enabled': false});
320
+ await plugin.commitBundle();
321
+
322
+ final updated = jsonDecode(store.data[bundleKey]!) as List;
323
+ expect(updated.length, equals(1));
324
+ expect(updated[0]['enabled'], isFalse);
325
+ });
326
+
327
+ test('throws when updating non-existent bundle', () async {
328
+ expect(
329
+ () => plugin.updateBundle('nonexistent', {'enabled': true}),
330
+ throwsA(isA<StateError>()),
331
+ );
332
+ });
333
+
334
+ test('deletes a bundle successfully', () async {
335
+ final b1 = createBundle(
336
+ 'production', 'ios', '1.1.1', 'bundleX');
337
+ final b2 = createBundle(
338
+ 'production', 'android', '1.1.1', 'bundleY');
339
+
340
+ await plugin.appendBundle(b1);
341
+ await plugin.appendBundle(b2);
342
+ await plugin.commitBundle();
343
+
344
+ expect(await plugin.getBundleById('bundleX'), isNotNull);
345
+
346
+ await plugin.deleteBundle(b1);
347
+ await plugin.commitBundle();
348
+
349
+ expect(await plugin.getBundleById('bundleX'), isNull);
350
+ expect(await plugin.getBundleById('bundleY'), isNotNull);
351
+ });
352
+
353
+ test('deletes entire update.json when no bundles remain', () async {
354
+ final b1 = createBundle(
355
+ 'production', 'ios', '1.1.1', 'bundleX');
356
+
357
+ await plugin.appendBundle(b1);
358
+ await plugin.commitBundle();
359
+
360
+ final updateJsonKeys =
361
+ store.data.keys.where((k) => k.contains('update.json')).toList();
362
+ expect(updateJsonKeys, isNotEmpty);
363
+
364
+ await plugin.deleteBundle(b1);
365
+ await plugin.commitBundle();
366
+
367
+ expect(store.data.containsKey(updateJsonKeys.first), isFalse);
368
+ });
369
+
370
+ test('keeps update.json when other bundles remain', () async {
371
+ final b1 = {..._defaultBundleFields, 'id': 'bundleA', 'channel': 'production',
372
+ 'platform': 'ios', 'targetAppVersion': '1.1.1'};
373
+ final b2 = {..._defaultBundleFields, 'id': 'bundleB', 'channel': 'production',
374
+ 'platform': 'ios', 'targetAppVersion': '1.1.1'};
375
+
376
+ final bundle1 = Bundle.fromJson(b1);
377
+ final bundle2 = Bundle.fromJson(b2);
378
+
379
+ await plugin.appendBundle(bundle1);
380
+ await plugin.appendBundle(bundle2);
381
+ await plugin.commitBundle();
382
+
383
+ await plugin.deleteBundle(bundle1);
384
+ await plugin.commitBundle();
385
+
386
+ final remaining = await plugin.getBundleById('bundleB');
387
+ expect(remaining, isNotNull);
388
+ });
389
+ });
390
+
391
+ group('targetAppVersion move', () {
392
+ test('moves bundle between version paths', () async {
393
+ final keyOld = 'production/ios/1.x.x/update.json';
394
+ final keyNew = 'production/ios/1.0.2/update.json';
395
+
396
+ final oldBundles = [
397
+ createBundle('production', 'ios', '1.x.x',
398
+ '00000000-0000-0000-0000-000000000003'),
399
+ createBundle('production', 'ios', '1.x.x',
400
+ '00000000-0000-0000-0000-000000000002'),
401
+ createBundle('production', 'ios', '1.x.x',
402
+ '00000000-0000-0000-0000-000000000001'),
403
+ ];
404
+
405
+ final newBundles = [
406
+ createBundle('production', 'ios', '1.0.2',
407
+ '00000000-0000-0000-0000-000000000005'),
408
+ createBundle('production', 'ios', '1.0.2',
409
+ '00000000-0000-0000-0000-000000000004'),
410
+ ];
411
+
412
+ store.data[keyOld] = jsonEncode(oldBundles.map((b) => b.toJson()).toList());
413
+ store.data[keyNew] = jsonEncode(newBundles.map((b) => b.toJson()).toList());
414
+ store.data['production/ios/target-app-versions.json'] =
415
+ jsonEncode(['1.x.x', '1.0.2']);
416
+
417
+ await plugin.getBundles(const DatabaseBundleQueryOptions(limit: 20));
418
+
419
+ await plugin.updateBundle('00000000-0000-0000-0000-000000000003', {
420
+ 'targetAppVersion': '1.0.2',
421
+ });
422
+ await plugin.commitBundle();
423
+
424
+ final newFileBundles = jsonDecode(store.data[keyNew]!) as List;
425
+ expect(newFileBundles.length, equals(3));
426
+
427
+ final oldFileBundles = jsonDecode(store.data[keyOld]!) as List;
428
+ expect(oldFileBundles.length, equals(2));
429
+ });
430
+ });
431
+
432
+ group('getChannels', () {
433
+ test('returns sorted unique channels', () async {
434
+ final p1 = createBundle('production', 'ios', '1.0.0', 'ch-prod');
435
+ final p2 = createBundle('beta', 'ios', '1.0.0', 'ch-beta');
436
+
437
+ await plugin.appendBundle(p1);
438
+ await plugin.appendBundle(p2);
439
+ await plugin.commitBundle();
440
+
441
+ final channels = await plugin.getChannels();
442
+ expect(channels, ['beta', 'production']);
443
+ });
444
+ });
445
+
446
+ group('getBundles', () {
447
+ test('returns empty for no data', () async {
448
+ final result =
449
+ await plugin.getBundles(const DatabaseBundleQueryOptions(limit: 20));
450
+ expect(result.data, isEmpty);
451
+ expect(result.pagination.total, equals(0));
452
+ });
453
+
454
+ test('sorts bundles descending by id', () async {
455
+ final bA = createBundle('production', 'ios', '1.0.0', 'A');
456
+ final bB = createBundle('production', 'ios', '1.0.0', 'B');
457
+ final bC = createBundle('production', 'ios', '2.0.0', 'C');
458
+
459
+ store.data['production/ios/1.0.0/update.json'] =
460
+ jsonEncode([bB.toJson(), bA.toJson()]);
461
+ store.data['production/ios/2.0.0/update.json'] =
462
+ jsonEncode([bC.toJson()]);
463
+
464
+ final result =
465
+ await plugin.getBundles(const DatabaseBundleQueryOptions(limit: 20));
466
+ expect(result.data.length, equals(3));
467
+ expect(result.data[0].id, 'C');
468
+ expect(result.data[1].id, 'B');
469
+ expect(result.data[2].id, 'A');
470
+ });
471
+
472
+ test('filters by channel and platform', () async {
473
+ final ios = createBundle('production', 'ios', '1.0.0', 'ios-1');
474
+ final android =
475
+ createBundle('production', 'android', '1.0.0', 'android-1');
476
+
477
+ store.data['production/ios/1.0.0/update.json'] =
478
+ jsonEncode([ios.toJson()]);
479
+ store.data['production/android/1.0.0/update.json'] =
480
+ jsonEncode([android.toJson()]);
481
+
482
+ final result = await plugin.getBundles(DatabaseBundleQueryOptions(
483
+ limit: 20,
484
+ where: const DatabaseBundleQueryWhere(
485
+ channel: 'production',
486
+ platform: Platform.ios,
487
+ ),
488
+ ));
489
+ expect(result.data.length, equals(1));
490
+ expect(result.data[0].id, 'ios-1');
491
+ });
492
+ });
493
+
494
+ group('CloudFront invalidation', () {
495
+ test('invalidates correct paths on new bundle', () async {
496
+ final bundle = createBundle('production', 'ios', '1.0.0',
497
+ 'cloudfront-new-test');
498
+
499
+ await plugin.appendBundle(bundle);
500
+ await plugin.commitBundle();
501
+
502
+ expect(store.invalidationCalls, isNotEmpty);
503
+ expect(
504
+ store.invalidationCalls
505
+ .any((p) => p.contains('app-version') && p.contains('1.0.0')),
506
+ isTrue,
507
+ );
508
+ expect(
509
+ store.invalidationCalls.any((p) => p.contains('update.json')),
510
+ isFalse,
511
+ );
512
+ });
513
+
514
+ test('invalidates semver pattern path', () async {
515
+ final bundle = createBundle('production', 'ios', '3.0.x',
516
+ 'cloudfront-semver');
517
+
518
+ store.invalidationCalls.clear();
519
+ await plugin.appendBundle(bundle);
520
+ await plugin.commitBundle();
521
+
522
+ expect(
523
+ store.invalidationCalls
524
+ .any((p) => p == '/api/check-update/app-version/ios/*'),
525
+ isTrue,
526
+ );
527
+ });
528
+
529
+ test('no invalidation when commitBundle has no changes', () async {
530
+ store.invalidationCalls.clear();
531
+ await plugin.commitBundle();
532
+ expect(store.invalidationCalls, isEmpty);
533
+ });
534
+ });
535
+
536
+ group('targetAppVersion with spaces', () {
537
+ test('normalizes space-containing targetAppVersion', () async {
538
+ final bundle = createBundle('production', 'ios', '>= 10.7.0',
539
+ 'space-normalization-test');
540
+
541
+ await plugin.appendBundle(bundle);
542
+ await plugin.commitBundle();
543
+
544
+ expect(store.data.containsKey('production/ios/>=10.7.0/update.json'),
545
+ isTrue);
546
+ });
547
+
548
+ test('normalizes multiple spaces', () async {
549
+ final bundle = createBundle('production', 'android', '> 1.0.0 < 2.0.0',
550
+ 'multi-space-test');
551
+
552
+ await plugin.appendBundle(bundle);
553
+ await plugin.commitBundle();
554
+
555
+ expect(
556
+ store.data.containsKey('production/android/>1.0.0 <2.0.0/update.json'),
557
+ isTrue);
558
+ });
559
+
560
+ test('handles various semver range formats', () async {
561
+ final cases = [
562
+ ('> 1.0.0', '>1.0.0'),
563
+ ('< 2.0.0', '<2.0.0'),
564
+ ('>= 1.0.0', '>=1.0.0'),
565
+ ('<= 2.0.0', '<=2.0.0'),
566
+ ('~ 1.0.0', '~1.0.0'),
567
+ ('^ 2.0.0', '^2.0.0'),
568
+ ];
569
+
570
+ for (var i = 0; i < cases.length; i++) {
571
+ final (version, normalized) = cases[i];
572
+ final bundle = createBundle('production', 'ios', version, 'format-$i');
573
+ await plugin.appendBundle(bundle);
574
+ }
575
+
576
+ await plugin.commitBundle();
577
+
578
+ for (final (_, normalized) in cases) {
579
+ expect(
580
+ store.data.containsKey('production/ios/$normalized/update.json'),
581
+ isTrue,
582
+ reason: 'Missing key for $normalized',
583
+ );
584
+ }
585
+ });
586
+
587
+ test('normalizes target-app-versions.json', () async {
588
+ final bundle = createBundle('production', 'ios', '>= 10.7.0',
589
+ 'target-versions-test');
590
+
591
+ await plugin.appendBundle(bundle);
592
+ await plugin.commitBundle();
593
+
594
+ final versions = jsonDecode(
595
+ store.data['production/ios/target-app-versions.json']!) as List;
596
+ expect(versions, contains('>=10.7.0'));
597
+ });
598
+ });
599
+
600
+ group('semver normalization', () {
601
+ test('invalidates all paths for 1.0.0', () async {
602
+ final bundle = createBundle('production', 'ios', '1.0.0',
603
+ 'semver-normalization-test');
604
+
605
+ store.invalidationCalls.clear();
606
+ await plugin.appendBundle(bundle);
607
+ await plugin.commitBundle();
608
+
609
+ expect(
610
+ store.invalidationCalls
611
+ .any((p) => p.contains('app-version/ios/1.0.0')),
612
+ isTrue,
613
+ );
614
+ expect(
615
+ store.invalidationCalls.any((p) => p.contains('app-version/ios/1.0')),
616
+ isTrue,
617
+ );
618
+ expect(
619
+ store.invalidationCalls.any((p) => p.contains('app-version/ios/1/')),
620
+ isTrue,
621
+ );
622
+ });
623
+
624
+ test('only normalizes 2.x when patch=0', () async {
625
+ final bundle = createBundle('production', 'android', '2.1.0',
626
+ 'semver-norm-test-2');
627
+
628
+ store.invalidationCalls.clear();
629
+ await plugin.appendBundle(bundle);
630
+ await plugin.commitBundle();
631
+
632
+ expect(
633
+ store.invalidationCalls
634
+ .any((p) => p.contains('app-version/android/2.1.0')),
635
+ isTrue,
636
+ );
637
+ expect(
638
+ store.invalidationCalls.any((p) => p.contains('app-version/android/2.1')),
639
+ isTrue,
640
+ );
641
+ expect(
642
+ store.invalidationCalls
643
+ .any((p) => RegExp(r'app-version/android/2/').hasMatch(p)),
644
+ isFalse,
645
+ );
646
+ });
647
+
648
+ test('no extra normalization for 1.2.3', () async {
649
+ final bundle = createBundle('production', 'ios', '1.2.3',
650
+ 'no-normalization-test');
651
+
652
+ store.invalidationCalls.clear();
653
+ await plugin.appendBundle(bundle);
654
+ await plugin.commitBundle();
655
+
656
+ expect(
657
+ store.invalidationCalls.any(
658
+ (p) => p == '/api/check-update/app-version/ios/1.2.3/production/*'),
659
+ isTrue,
660
+ );
661
+ expect(
662
+ store.invalidationCalls.any(
663
+ (p) => p == '/api/check-update/app-version/ios/1.2/production/*'),
664
+ isFalse,
665
+ );
666
+ expect(
667
+ store.invalidationCalls.any(
668
+ (p) => p == '/api/check-update/app-version/ios/1/production/*'),
669
+ isFalse,
670
+ );
671
+ });
672
+ });
673
+
674
+ group('issue #745 promotion scenario', () {
675
+ test('updates target-app-versions when promoting channel', () async {
676
+ final bundle = createBundle('test', 'android', '8.1.3',
677
+ 'issue-745-promote-bundle');
678
+
679
+ await plugin.appendBundle(bundle);
680
+ await plugin.commitBundle();
681
+
682
+ store.invalidationCalls.clear();
683
+
684
+ await plugin.updateBundle('issue-745-promote-bundle', {
685
+ 'channel': 'prod',
686
+ });
687
+ await plugin.commitBundle();
688
+
689
+ expect(
690
+ store.invalidationCalls
691
+ .any((p) => p.contains('app-version/android/8.1.3/test')),
692
+ isTrue,
693
+ );
694
+ expect(
695
+ store.invalidationCalls
696
+ .any((p) => p.contains('app-version/android/8.1.3/prod')),
697
+ isTrue,
698
+ );
699
+
700
+ final prodVersions = jsonDecode(
701
+ store.data['prod/android/target-app-versions.json'] ?? '[]')
702
+ as List;
703
+ expect(prodVersions, contains('8.1.3'));
704
+
705
+ final testVersions = jsonDecode(
706
+ store.data['test/android/target-app-versions.json'] ?? '[]')
707
+ as List;
708
+ expect(testVersions, isNot(contains('8.1.3')));
709
+ });
710
+ });
711
+ }