@_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,1600 @@
1
+ import 'package:flutter_patcher_core/flutter_patcher_core.dart';
2
+ import 'package:flutter_patcher_plugin_core/src/get_update_info.dart';
3
+ import 'package:test/test.dart';
4
+
5
+ const _defaultBundleMessage = 'hello';
6
+ const _defaultStorageUri = 'storage://my-app/bundle.zip';
7
+ const _defaultFileHash = 'hash';
8
+
9
+ Bundle _appVersionBundle({
10
+ required String id,
11
+ String? targetAppVersion,
12
+ bool enabled = true,
13
+ bool shouldForceUpdate = false,
14
+ String channel = 'production',
15
+ String? message,
16
+ int? rolloutCohortCount,
17
+ List<String>? targetCohorts,
18
+ }) =>
19
+ Bundle(
20
+ id: id,
21
+ platform: Platform.android,
22
+ shouldForceUpdate: shouldForceUpdate,
23
+ enabled: enabled,
24
+ fileHash: _defaultFileHash,
25
+ storageUri: _defaultStorageUri,
26
+ channel: channel,
27
+ targetAppVersion: targetAppVersion,
28
+ message: message ?? _defaultBundleMessage,
29
+ rolloutCohortCount: rolloutCohortCount,
30
+ targetCohorts: targetCohorts,
31
+ );
32
+
33
+ Bundle _fingerprintBundle({
34
+ required String id,
35
+ required String fingerprintHash,
36
+ bool enabled = true,
37
+ bool shouldForceUpdate = false,
38
+ String channel = 'production',
39
+ String? message,
40
+ int? rolloutCohortCount,
41
+ List<String>? targetCohorts,
42
+ }) =>
43
+ Bundle(
44
+ id: id,
45
+ platform: Platform.android,
46
+ shouldForceUpdate: shouldForceUpdate,
47
+ enabled: enabled,
48
+ fileHash: _defaultFileHash,
49
+ storageUri: _defaultStorageUri,
50
+ channel: channel,
51
+ fingerprintHash: fingerprintHash,
52
+ message: message ?? _defaultBundleMessage,
53
+ rolloutCohortCount: rolloutCohortCount,
54
+ targetCohorts: targetCohorts,
55
+ );
56
+
57
+ AppVersionGetBundlesArgs _appArgs({
58
+ String appVersion = '1.0',
59
+ String bundleId = nilUuid,
60
+ String? minBundleId,
61
+ String channel = 'production',
62
+ String? cohort,
63
+ }) =>
64
+ AppVersionGetBundlesArgs(
65
+ platform: Platform.android,
66
+ bundleId: bundleId,
67
+ minBundleId: minBundleId ?? nilUuid,
68
+ channel: channel,
69
+ cohort: cohort,
70
+ appVersion: appVersion,
71
+ );
72
+
73
+ FingerprintGetBundlesArgs _fpArgs({
74
+ String fingerprintHash = 'hash1',
75
+ String bundleId = nilUuid,
76
+ String? minBundleId,
77
+ String channel = 'production',
78
+ String? cohort,
79
+ }) =>
80
+ FingerprintGetBundlesArgs(
81
+ platform: Platform.android,
82
+ bundleId: bundleId,
83
+ minBundleId: minBundleId ?? nilUuid,
84
+ channel: channel,
85
+ cohort: cohort,
86
+ fingerprintHash: fingerprintHash,
87
+ );
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // App version strategy
91
+ // ---------------------------------------------------------------------------
92
+ void main() {
93
+ group('getUpdateInfo — app version strategy', () {
94
+ test('applies update when a * bundle is available', () async {
95
+ final bundles = [
96
+ _appVersionBundle(
97
+ id: '00000000-0000-0000-0000-000000000001',
98
+ targetAppVersion: '*',
99
+ ),
100
+ ];
101
+
102
+ final update = await getUpdateInfo(bundles, _appArgs());
103
+
104
+ expect(update, isNotNull);
105
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
106
+ expect(update.message, 'hello');
107
+ expect(update.shouldForceUpdate, isFalse);
108
+ expect(update.status, UpdateStatus.update);
109
+ });
110
+
111
+ test('returns null when no bundles are provided', () async {
112
+ final update = await getUpdateInfo([], _appArgs());
113
+ expect(update, isNull);
114
+ });
115
+
116
+ test('returns null when app version does not qualify for higher version',
117
+ () async {
118
+ final bundles = [
119
+ _appVersionBundle(
120
+ id: '00000000-0000-0000-0000-000000000001',
121
+ targetAppVersion: '1.1',
122
+ ),
123
+ ];
124
+
125
+ final update = await getUpdateInfo(bundles, _appArgs());
126
+ expect(update, isNull);
127
+ });
128
+
129
+ test('target app version compatibility with available higher version',
130
+ () async {
131
+ final bundles = [
132
+ _appVersionBundle(
133
+ id: '01963024-c131-7971-8725-ab47e232df41',
134
+ targetAppVersion: '1.0.0',
135
+ ),
136
+ _appVersionBundle(
137
+ id: '01963024-c131-7971-8725-ab47e232df42',
138
+ targetAppVersion: '1.0.1',
139
+ ),
140
+ ];
141
+
142
+ final update = await getUpdateInfo(
143
+ bundles,
144
+ _appArgs(bundleId: '01963024-c131-7971-8725-ab47e232df41'),
145
+ );
146
+ expect(update, isNull);
147
+ });
148
+
149
+ test('applies update when higher semver-compatible bundle is available',
150
+ () async {
151
+ final bundles = [
152
+ _appVersionBundle(
153
+ id: '00000000-0000-0000-0000-000000000001',
154
+ targetAppVersion: '1.x.x',
155
+ ),
156
+ _appVersionBundle(
157
+ id: '00000000-0000-0000-0000-000000000002',
158
+ targetAppVersion: '1.0',
159
+ ),
160
+ ];
161
+
162
+ final update = await getUpdateInfo(bundles, _appArgs());
163
+ expect(update, isNotNull);
164
+ expect(update!.id, '00000000-0000-0000-0000-000000000002');
165
+ expect(update.shouldForceUpdate, isFalse);
166
+ expect(update.status, UpdateStatus.update);
167
+ expect(update.message, 'hello');
168
+ });
169
+
170
+ test('applies update when shouldForceUpdate is true', () async {
171
+ final bundles = [
172
+ _appVersionBundle(
173
+ id: '00000000-0000-0000-0000-000000000001',
174
+ targetAppVersion: '1.0',
175
+ shouldForceUpdate: true,
176
+ ),
177
+ ];
178
+
179
+ final update = await getUpdateInfo(bundles, _appArgs());
180
+ expect(update, isNotNull);
181
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
182
+ expect(update.shouldForceUpdate, isTrue);
183
+ expect(update.status, UpdateStatus.update);
184
+ });
185
+
186
+ test('applies update when shouldForceUpdate is false', () async {
187
+ final bundles = [
188
+ _appVersionBundle(
189
+ id: '00000000-0000-0000-0000-000000000001',
190
+ targetAppVersion: '1.0',
191
+ shouldForceUpdate: false,
192
+ ),
193
+ ];
194
+
195
+ final update = await getUpdateInfo(bundles, _appArgs());
196
+ expect(update, isNotNull);
197
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
198
+ expect(update.shouldForceUpdate, isFalse);
199
+ expect(update.status, UpdateStatus.update);
200
+ });
201
+
202
+ test('applies update when bundle is still considered higher', () async {
203
+ final bundles = [
204
+ _appVersionBundle(
205
+ id: '00000000-0000-0000-0000-000000000005',
206
+ targetAppVersion: '1.0',
207
+ ),
208
+ ];
209
+
210
+ final update = await getUpdateInfo(bundles, _appArgs());
211
+ expect(update, isNotNull);
212
+ expect(update!.id, '00000000-0000-0000-0000-000000000005');
213
+ expect(update.shouldForceUpdate, isFalse);
214
+ expect(update.message, 'hello');
215
+ expect(update.status, UpdateStatus.update);
216
+ });
217
+
218
+ test('falls back to older enabled bundle when latest is disabled', () async {
219
+ final bundles = [
220
+ _appVersionBundle(
221
+ id: '00000000-0000-0000-0000-000000000002',
222
+ targetAppVersion: '1.0',
223
+ shouldForceUpdate: true,
224
+ enabled: false,
225
+ ),
226
+ _appVersionBundle(
227
+ id: '00000000-0000-0000-0000-000000000001',
228
+ targetAppVersion: '1.0',
229
+ shouldForceUpdate: false,
230
+ ),
231
+ ];
232
+
233
+ final update = await getUpdateInfo(bundles, _appArgs());
234
+ expect(update, isNotNull);
235
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
236
+ expect(update.shouldForceUpdate, isFalse);
237
+ expect(update.status, UpdateStatus.update);
238
+ });
239
+
240
+ test('returns null if all bundles are disabled', () async {
241
+ final bundles = [
242
+ _appVersionBundle(
243
+ id: '00000000-0000-0000-0000-000000000002',
244
+ targetAppVersion: '1.0',
245
+ enabled: false,
246
+ ),
247
+ _appVersionBundle(
248
+ id: '00000000-0000-0000-0000-000000000001',
249
+ targetAppVersion: '1.0',
250
+ enabled: false,
251
+ ),
252
+ ];
253
+
254
+ final update = await getUpdateInfo(bundles, _appArgs());
255
+ expect(update, isNull);
256
+ });
257
+
258
+ test('rollback if latest bundle is disabled and no other updates enabled',
259
+ () async {
260
+ final bundles = [
261
+ _appVersionBundle(
262
+ id: '00000000-0000-0000-0000-000000000002',
263
+ targetAppVersion: '1.0',
264
+ enabled: false,
265
+ ),
266
+ _appVersionBundle(
267
+ id: '00000000-0000-0000-0000-000000000001',
268
+ targetAppVersion: '1.0',
269
+ enabled: false,
270
+ ),
271
+ ];
272
+
273
+ final update = await getUpdateInfo(bundles, _appArgs());
274
+ expect(update, isNull);
275
+ });
276
+
277
+ test('applies update when same-version bundle is available', () async {
278
+ final bundles = [
279
+ _appVersionBundle(
280
+ id: '00000000-0000-0000-0000-000000000001',
281
+ targetAppVersion: '1.0',
282
+ message: 'hi',
283
+ ),
284
+ ];
285
+
286
+ final update = await getUpdateInfo(bundles, _appArgs());
287
+ expect(update, isNotNull);
288
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
289
+ expect(update.shouldForceUpdate, isFalse);
290
+ expect(update.status, UpdateStatus.update);
291
+ expect(update.message, 'hi');
292
+ });
293
+
294
+ test('forces rollback if no matching bundle exists for provided bundleId',
295
+ () async {
296
+ final update = await getUpdateInfo(
297
+ [],
298
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
299
+ );
300
+ expect(update, isNotNull);
301
+ expect(update!.id, nilUuid);
302
+ expect(update.shouldForceUpdate, isTrue);
303
+ expect(update.status, UpdateStatus.rollback);
304
+ expect(update.message, isNull);
305
+ });
306
+
307
+ test('returns null if user is already up-to-date', () async {
308
+ final bundles = [
309
+ _appVersionBundle(
310
+ id: '00000000-0000-0000-0000-000000000002',
311
+ targetAppVersion: '1.0',
312
+ ),
313
+ _appVersionBundle(
314
+ id: '00000000-0000-0000-0000-000000000001',
315
+ targetAppVersion: '1.0',
316
+ ),
317
+ ];
318
+
319
+ final update = await getUpdateInfo(
320
+ bundles,
321
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
322
+ );
323
+ expect(update, isNull);
324
+ });
325
+
326
+ test('rollback if previously used bundle no longer exists', () async {
327
+ final bundles = [
328
+ _appVersionBundle(
329
+ id: '00000000-0000-0000-0000-000000000001',
330
+ targetAppVersion: '1.0',
331
+ ),
332
+ ];
333
+
334
+ final update = await getUpdateInfo(
335
+ bundles,
336
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
337
+ );
338
+ expect(update, isNotNull);
339
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
340
+ expect(update.shouldForceUpdate, isTrue);
341
+ expect(update.status, UpdateStatus.rollback);
342
+ });
343
+
344
+ test('selects next available bundle even if shouldForceUpdate is false',
345
+ () async {
346
+ final bundles = [
347
+ _appVersionBundle(
348
+ id: '00000000-0000-0000-0000-000000000003',
349
+ targetAppVersion: '1.0',
350
+ ),
351
+ _appVersionBundle(
352
+ id: '00000000-0000-0000-0000-000000000002',
353
+ targetAppVersion: '1.0',
354
+ ),
355
+ _appVersionBundle(
356
+ id: '00000000-0000-0000-0000-000000000001',
357
+ targetAppVersion: '1.0',
358
+ ),
359
+ ];
360
+
361
+ final update = await getUpdateInfo(
362
+ bundles,
363
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
364
+ );
365
+ expect(update, isNotNull);
366
+ expect(update!.id, '00000000-0000-0000-0000-000000000003');
367
+ expect(update.shouldForceUpdate, isFalse);
368
+ expect(update.status, UpdateStatus.update);
369
+ });
370
+
371
+ test('applies highest available bundle even if app version unchanged',
372
+ () async {
373
+ final bundles = [
374
+ _appVersionBundle(
375
+ id: '00000000-0000-0000-0000-000000000005',
376
+ targetAppVersion: '1.0',
377
+ ),
378
+ _appVersionBundle(
379
+ id: '00000000-0000-0000-0000-000000000004',
380
+ targetAppVersion: '1.0',
381
+ ),
382
+ _appVersionBundle(
383
+ id: '00000000-0000-0000-0000-000000000003',
384
+ targetAppVersion: '1.0',
385
+ ),
386
+ _appVersionBundle(
387
+ id: '00000000-0000-0000-0000-000000000002',
388
+ targetAppVersion: '1.0',
389
+ ),
390
+ _appVersionBundle(
391
+ id: '00000000-0000-0000-0000-000000000001',
392
+ targetAppVersion: '1.0',
393
+ ),
394
+ ];
395
+
396
+ final update = await getUpdateInfo(
397
+ bundles,
398
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
399
+ );
400
+ expect(update, isNotNull);
401
+ expect(update!.id, '00000000-0000-0000-0000-000000000005');
402
+ expect(update.shouldForceUpdate, isFalse);
403
+ expect(update.status, UpdateStatus.update);
404
+ });
405
+
406
+ test('returns null if newest matching bundle is disabled', () async {
407
+ final bundles = [
408
+ _appVersionBundle(
409
+ id: '00000000-0000-0000-0000-000000000003',
410
+ targetAppVersion: '1.0',
411
+ enabled: false,
412
+ ),
413
+ _appVersionBundle(
414
+ id: '00000000-0000-0000-0000-000000000002',
415
+ targetAppVersion: '1.0',
416
+ shouldForceUpdate: true,
417
+ ),
418
+ _appVersionBundle(
419
+ id: '00000000-0000-0000-0000-000000000001',
420
+ targetAppVersion: '1.0',
421
+ ),
422
+ ];
423
+
424
+ final update = await getUpdateInfo(
425
+ bundles,
426
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
427
+ );
428
+ expect(update, isNull);
429
+ });
430
+
431
+ test('rolls back to older enabled bundle if current is disabled', () async {
432
+ final bundles = [
433
+ _appVersionBundle(
434
+ id: '00000000-0000-0000-0000-000000000002',
435
+ targetAppVersion: '1.0',
436
+ enabled: false,
437
+ ),
438
+ _appVersionBundle(
439
+ id: '00000000-0000-0000-0000-000000000001',
440
+ targetAppVersion: '1.0',
441
+ ),
442
+ ];
443
+
444
+ final update = await getUpdateInfo(
445
+ bundles,
446
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
447
+ );
448
+ expect(update, isNotNull);
449
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
450
+ expect(update.shouldForceUpdate, isTrue);
451
+ expect(update.status, UpdateStatus.rollback);
452
+ });
453
+
454
+ test('rollback to init bundle when all bundles are disabled', () async {
455
+ final bundles = [
456
+ _appVersionBundle(
457
+ id: '00000000-0000-0000-0000-000000000002',
458
+ targetAppVersion: '1.0',
459
+ enabled: false,
460
+ ),
461
+ _appVersionBundle(
462
+ id: '00000000-0000-0000-0000-000000000001',
463
+ targetAppVersion: '1.0',
464
+ enabled: false,
465
+ ),
466
+ ];
467
+
468
+ final update = await getUpdateInfo(
469
+ bundles,
470
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
471
+ );
472
+ expect(update, isNotNull);
473
+ expect(update!.id, nilUuid);
474
+ expect(update.shouldForceUpdate, isTrue);
475
+ expect(update.status, UpdateStatus.rollback);
476
+ });
477
+
478
+ test('returns null when bundle lower than minBundleId', () async {
479
+ final bundles = [
480
+ _appVersionBundle(
481
+ id: '0195715a-ce29-7c55-97d3-53af4fe369b7',
482
+ targetAppVersion: '1.0',
483
+ ),
484
+ ];
485
+
486
+ final update = await getUpdateInfo(
487
+ bundles,
488
+ _appArgs(
489
+ minBundleId: '0195715b-9591-7000-8000-000000000000',
490
+ bundleId: '0195715b-9591-7000-8000-000000000000',
491
+ ),
492
+ );
493
+ expect(update, isNull);
494
+ });
495
+
496
+ test('returns bundle when available bundle higher than minBundleId',
497
+ () async {
498
+ final bundles = [
499
+ _appVersionBundle(
500
+ id: '0195715d-42db-7475-9204-31819efc2f1d',
501
+ targetAppVersion: '1.0',
502
+ ),
503
+ _appVersionBundle(
504
+ id: '0195715a-ce29-7c55-97d3-53af4fe369b7',
505
+ targetAppVersion: '1.0',
506
+ ),
507
+ ];
508
+
509
+ final update = await getUpdateInfo(
510
+ bundles,
511
+ _appArgs(
512
+ minBundleId: '0195715b-9591-7000-8000-000000000000',
513
+ bundleId: '0195715b-9591-7000-8000-000000000000',
514
+ ),
515
+ );
516
+ expect(update, isNotNull);
517
+ expect(update!.id, '0195715d-42db-7475-9204-31819efc2f1d');
518
+ expect(update.shouldForceUpdate, isFalse);
519
+ expect(update.status, UpdateStatus.update);
520
+ });
521
+
522
+ test('rollback when current bundle disabled and only bundles below minBundleId',
523
+ () async {
524
+ final bundles = [
525
+ _appVersionBundle(
526
+ id: '0195715d-42db-7475-9204-31819efc2f1d',
527
+ targetAppVersion: '1.0',
528
+ enabled: false,
529
+ ),
530
+ _appVersionBundle(
531
+ id: '0195715a-ce29-7c55-97d3-53af4fe369b7',
532
+ targetAppVersion: '1.0',
533
+ ),
534
+ ];
535
+
536
+ final update = await getUpdateInfo(
537
+ bundles,
538
+ _appArgs(
539
+ minBundleId: '0195715b-9591-7000-8000-000000000000',
540
+ bundleId: '0195715d-42db-7475-9204-31819efc2f1d',
541
+ ),
542
+ );
543
+ expect(update, isNotNull);
544
+ expect(update!.id, nilUuid);
545
+ expect(update.shouldForceUpdate, isTrue);
546
+ expect(update.status, UpdateStatus.rollback);
547
+ });
548
+
549
+ test('rollback when current bundle does not exist and only below minBundleId',
550
+ () async {
551
+ final bundles = [
552
+ _appVersionBundle(
553
+ id: '0195715a-ce29-7c55-97d3-53af4fe369b7',
554
+ targetAppVersion: '1.0',
555
+ ),
556
+ ];
557
+
558
+ final update = await getUpdateInfo(
559
+ bundles,
560
+ _appArgs(
561
+ minBundleId: '0195715b-9591-7000-8000-000000000000',
562
+ bundleId: '0195715d-42db-7475-9204-31819efc2f1d',
563
+ ),
564
+ );
565
+ expect(update, isNotNull);
566
+ expect(update!.id, nilUuid);
567
+ expect(update.shouldForceUpdate, isTrue);
568
+ expect(update.status, UpdateStatus.rollback);
569
+ });
570
+
571
+ test('returns null when current bundle enabled and no updates available',
572
+ () async {
573
+ final bundles = [
574
+ _appVersionBundle(
575
+ id: '0195715d-42db-7475-9204-31819efc2f1d',
576
+ targetAppVersion: '1.0',
577
+ ),
578
+ _appVersionBundle(
579
+ id: '0195715a-ce29-7c55-97d3-53af4fe369b7',
580
+ targetAppVersion: '1.0',
581
+ ),
582
+ ];
583
+
584
+ final update = await getUpdateInfo(
585
+ bundles,
586
+ _appArgs(
587
+ minBundleId: '0195715b-9591-7000-8000-000000000000',
588
+ bundleId: '0195715d-42db-7475-9204-31819efc2f1d',
589
+ ),
590
+ );
591
+ expect(update, isNull);
592
+ });
593
+
594
+ test('rollback when current bundle does not exist and all below minBundleId',
595
+ () async {
596
+ final bundles = [
597
+ _appVersionBundle(
598
+ id: '01957165-bee7-7df3-a25d-6686f01b02ba',
599
+ targetAppVersion: '1.0',
600
+ ),
601
+ _appVersionBundle(
602
+ id: '01957165-19fb-75af-a361-131c17a65ef2',
603
+ targetAppVersion: '1.0',
604
+ ),
605
+ _appVersionBundle(
606
+ id: '01957164-fbc6-785f-98ce-a6ae459f6e4f',
607
+ targetAppVersion: '1.0',
608
+ ),
609
+ ];
610
+
611
+ final update = await getUpdateInfo(
612
+ bundles,
613
+ _appArgs(
614
+ minBundleId: '01957166-6e63-7000-8000-000000000000',
615
+ bundleId: '01957167-0389-7064-8d86-f8af7950daed',
616
+ ),
617
+ );
618
+ expect(update, isNotNull);
619
+ expect(update!.id, nilUuid);
620
+ expect(update.shouldForceUpdate, isTrue);
621
+ expect(update.status, UpdateStatus.rollback);
622
+ });
623
+
624
+ test('rollback to bundle when current does not exist and bundle between minBundleId and bundleId',
625
+ () async {
626
+ final bundles = [
627
+ _appVersionBundle(
628
+ id: '0195716c-82f5-7e5e-ac8c-d4fbf5bc7555',
629
+ targetAppVersion: '1.0',
630
+ ),
631
+ _appVersionBundle(
632
+ id: '01957167-0389-7064-8d86-f8af7950daed',
633
+ targetAppVersion: '1.0',
634
+ ),
635
+ _appVersionBundle(
636
+ id: '01957165-bee7-7df3-a25d-6686f01b02ba',
637
+ targetAppVersion: '1.0',
638
+ ),
639
+ _appVersionBundle(
640
+ id: '01957165-19fb-75af-a361-131c17a65ef2',
641
+ targetAppVersion: '1.0',
642
+ ),
643
+ _appVersionBundle(
644
+ id: '01957164-fbc6-785f-98ce-a6ae459f6e4f',
645
+ targetAppVersion: '1.0',
646
+ ),
647
+ ];
648
+
649
+ final update = await getUpdateInfo(
650
+ bundles,
651
+ _appArgs(
652
+ minBundleId: '01957166-6e63-7000-8000-000000000000',
653
+ bundleId: '0195716c-d426-7308-9924-c3f8cb2eaaad',
654
+ ),
655
+ );
656
+ expect(update, isNotNull);
657
+ expect(update!.id, '0195716c-82f5-7e5e-ac8c-d4fbf5bc7555');
658
+ expect(update.shouldForceUpdate, isTrue);
659
+ expect(update.status, UpdateStatus.rollback);
660
+ });
661
+
662
+ test('returns null when installed bundleId equals minBundleId', () async {
663
+ final bundles = [
664
+ _appVersionBundle(
665
+ id: '01957179-d99d-7fbb-bc1e-feff6b3236f0',
666
+ targetAppVersion: '1.0',
667
+ ),
668
+ ];
669
+
670
+ final update = await getUpdateInfo(
671
+ bundles,
672
+ _appArgs(
673
+ minBundleId: '0195715b-9591-7000-8000-000000000000',
674
+ bundleId: '01957179-d99d-7fbb-bc1e-feff6b3236f0',
675
+ ),
676
+ );
677
+ expect(update, isNull);
678
+ });
679
+
680
+ test('does not update bundles from different channels', () async {
681
+ final bundles = [
682
+ _appVersionBundle(
683
+ id: '00000000-0000-0000-0000-000000000001',
684
+ targetAppVersion: '1.0',
685
+ channel: 'beta',
686
+ ),
687
+ ];
688
+
689
+ final update = await getUpdateInfo(
690
+ bundles,
691
+ _appArgs(channel: 'production'),
692
+ );
693
+ expect(update, isNull);
694
+ });
695
+
696
+ test('updates bundles from the same channel', () async {
697
+ final bundles = [
698
+ _appVersionBundle(
699
+ id: '00000000-0000-0000-0000-000000000001',
700
+ targetAppVersion: '1.0',
701
+ channel: 'beta',
702
+ ),
703
+ ];
704
+
705
+ final update = await getUpdateInfo(
706
+ bundles,
707
+ _appArgs(channel: 'beta'),
708
+ );
709
+ expect(update, isNotNull);
710
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
711
+ expect(update.message, 'hello');
712
+ expect(update.shouldForceUpdate, isFalse);
713
+ expect(update.status, UpdateStatus.update);
714
+ });
715
+
716
+ test('returns null when minBundleId is greater than current bundle', () async {
717
+ final bundles = [
718
+ _appVersionBundle(
719
+ id: '01957b63-7d11-7281-b8e7-1120ccfdb8ab',
720
+ targetAppVersion: '1.0',
721
+ ),
722
+ ];
723
+
724
+ final update = await getUpdateInfo(
725
+ bundles,
726
+ _appArgs(
727
+ bundleId: '01957b63-7d11-7281-b8e7-1120ccfdb8ab',
728
+ minBundleId: '01957bb4-b13c-7000-8000-000000000000',
729
+ ),
730
+ );
731
+ expect(update, isNull);
732
+ });
733
+
734
+ test('returns null when no bundles and minBundleId equals bundleId', () async {
735
+ final update = await getUpdateInfo(
736
+ [],
737
+ _appArgs(
738
+ bundleId: '0195d325-767a-7000-8000-000000000000',
739
+ minBundleId: '0195d325-767a-7000-8000-000000000000',
740
+ ),
741
+ );
742
+ expect(update, isNull);
743
+ });
744
+
745
+ test('applies update when bundle higher than minBundleId and bundleId equals minBundleId',
746
+ () async {
747
+ final bundles = [
748
+ _appVersionBundle(
749
+ id: '01963024-c131-7971-8725-ab47e232df40',
750
+ targetAppVersion: '1.0.0',
751
+ shouldForceUpdate: true,
752
+ ),
753
+ ];
754
+
755
+ final update = await getUpdateInfo(
756
+ bundles,
757
+ _appArgs(
758
+ bundleId: '00000000-0000-0000-0000-000000000000',
759
+ minBundleId: '00000000-0000-0000-0000-000000000000',
760
+ ),
761
+ );
762
+ expect(update, isNotNull);
763
+ expect(update!.id, '01963024-c131-7971-8725-ab47e232df40');
764
+ expect(update.message, 'hello');
765
+ expect(update.shouldForceUpdate, isTrue);
766
+ expect(update.status, UpdateStatus.update);
767
+ });
768
+
769
+ test('applies update for bounded range >= 5.7.0 <= 5.7.4', () async {
770
+ final bundles = [
771
+ _appVersionBundle(
772
+ id: '00000000-0000-0000-0000-000000000001',
773
+ targetAppVersion: '>= 5.7.0 <= 5.7.4',
774
+ ),
775
+ ];
776
+
777
+ final update = await getUpdateInfo(
778
+ bundles,
779
+ _appArgs(appVersion: '5.7.3'),
780
+ );
781
+ expect(update, isNotNull);
782
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
783
+ expect(update.shouldForceUpdate, isFalse);
784
+ expect(update.status, UpdateStatus.update);
785
+ });
786
+
787
+ test('returns null for bounded range >= 5.7.0 <= 5.7.4 with version 5.7.5',
788
+ () async {
789
+ final bundles = [
790
+ _appVersionBundle(
791
+ id: '00000000-0000-0000-0000-000000000001',
792
+ targetAppVersion: '>= 5.7.0 <= 5.7.4',
793
+ ),
794
+ ];
795
+
796
+ final update = await getUpdateInfo(
797
+ bundles,
798
+ _appArgs(appVersion: '5.7.5'),
799
+ );
800
+ expect(update, isNull);
801
+ });
802
+
803
+ test('applies update when many distinct target app versions are compatible',
804
+ () async {
805
+ final bundles = <Bundle>[];
806
+ for (var index = 0; index < 200; index++) {
807
+ final bundleNumber = index + 1;
808
+ bundles.add(
809
+ _appVersionBundle(
810
+ id: '00000000-0000-0000-0000-${bundleNumber.toString().padLeft(12, '0')}',
811
+ targetAppVersion: '>=0.$index.0',
812
+ ),
813
+ );
814
+ }
815
+
816
+ final update = await getUpdateInfo(bundles, _appArgs(appVersion: '1.0.0'));
817
+ expect(update, isNotNull);
818
+ expect(update!.id, '00000000-0000-0000-0000-000000000200');
819
+ expect(update.message, 'hello');
820
+ expect(update.shouldForceUpdate, isFalse);
821
+ expect(update.status, UpdateStatus.update);
822
+ });
823
+ });
824
+
825
+ // ---------------------------------------------------------------------------
826
+ // Fingerprint strategy
827
+ // ---------------------------------------------------------------------------
828
+ group('getUpdateInfo — fingerprint strategy', () {
829
+ test('returns null when no bundles are provided', () async {
830
+ final update = await getUpdateInfo([], _fpArgs());
831
+ expect(update, isNull);
832
+ });
833
+
834
+ test('returns null when fingerprint hash does not match', () async {
835
+ final bundles = [
836
+ _fingerprintBundle(
837
+ id: '00000000-0000-0000-0000-000000000001',
838
+ fingerprintHash: 'hash2',
839
+ ),
840
+ ];
841
+
842
+ final update = await getUpdateInfo(
843
+ bundles,
844
+ _fpArgs(fingerprintHash: 'hash1'),
845
+ );
846
+ expect(update, isNull);
847
+ });
848
+
849
+ test('fingerprint hash compatibility with available higher version',
850
+ () async {
851
+ final bundles = [
852
+ _fingerprintBundle(
853
+ id: '01963024-c131-7971-8725-ab47e232df41',
854
+ fingerprintHash: 'hash1',
855
+ ),
856
+ _fingerprintBundle(
857
+ id: '01963024-c131-7971-8725-ab47e232df42',
858
+ fingerprintHash: 'hash2',
859
+ ),
860
+ ];
861
+
862
+ final update = await getUpdateInfo(
863
+ bundles,
864
+ _fpArgs(bundleId: '01963024-c131-7971-8725-ab47e232df41'),
865
+ );
866
+ expect(update, isNull);
867
+ });
868
+
869
+ test('applies update when higher fingerprint-matching bundle available',
870
+ () async {
871
+ final bundles = [
872
+ _fingerprintBundle(
873
+ id: '00000000-0000-0000-0000-000000000001',
874
+ fingerprintHash: 'hash1',
875
+ ),
876
+ _fingerprintBundle(
877
+ id: '00000000-0000-0000-0000-000000000002',
878
+ fingerprintHash: 'hash2',
879
+ ),
880
+ ];
881
+
882
+ final update = await getUpdateInfo(bundles, _fpArgs());
883
+ expect(update, isNotNull);
884
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
885
+ expect(update.shouldForceUpdate, isFalse);
886
+ expect(update.status, UpdateStatus.update);
887
+ expect(update.message, 'hello');
888
+ });
889
+
890
+ test('applies update when shouldForceUpdate is true', () async {
891
+ final bundles = [
892
+ _fingerprintBundle(
893
+ id: '00000000-0000-0000-0000-000000000001',
894
+ fingerprintHash: 'hash1',
895
+ shouldForceUpdate: true,
896
+ ),
897
+ ];
898
+
899
+ final update = await getUpdateInfo(bundles, _fpArgs());
900
+ expect(update, isNotNull);
901
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
902
+ expect(update.shouldForceUpdate, isTrue);
903
+ expect(update.status, UpdateStatus.update);
904
+ });
905
+
906
+ test('applies update when shouldForceUpdate is false', () async {
907
+ final bundles = [
908
+ _fingerprintBundle(
909
+ id: '00000000-0000-0000-0000-000000000001',
910
+ fingerprintHash: 'hash1',
911
+ shouldForceUpdate: false,
912
+ ),
913
+ ];
914
+
915
+ final update = await getUpdateInfo(bundles, _fpArgs());
916
+ expect(update, isNotNull);
917
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
918
+ expect(update.shouldForceUpdate, isFalse);
919
+ expect(update.status, UpdateStatus.update);
920
+ });
921
+
922
+ test('applies update when bundle is still considered higher', () async {
923
+ final bundles = [
924
+ _fingerprintBundle(
925
+ id: '00000000-0000-0000-0000-000000000005',
926
+ fingerprintHash: 'hash1',
927
+ ),
928
+ ];
929
+
930
+ final update = await getUpdateInfo(bundles, _fpArgs());
931
+ expect(update, isNotNull);
932
+ expect(update!.id, '00000000-0000-0000-0000-000000000005');
933
+ expect(update.shouldForceUpdate, isFalse);
934
+ expect(update.message, 'hello');
935
+ expect(update.status, UpdateStatus.update);
936
+ });
937
+
938
+ test('falls back to older enabled bundle when latest is disabled', () async {
939
+ final bundles = [
940
+ _fingerprintBundle(
941
+ id: '00000000-0000-0000-0000-000000000002',
942
+ fingerprintHash: 'hash1',
943
+ shouldForceUpdate: true,
944
+ enabled: false,
945
+ ),
946
+ _fingerprintBundle(
947
+ id: '00000000-0000-0000-0000-000000000001',
948
+ fingerprintHash: 'hash1',
949
+ shouldForceUpdate: false,
950
+ ),
951
+ ];
952
+
953
+ final update = await getUpdateInfo(bundles, _fpArgs());
954
+ expect(update, isNotNull);
955
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
956
+ expect(update.shouldForceUpdate, isFalse);
957
+ expect(update.status, UpdateStatus.update);
958
+ });
959
+
960
+ test('returns null if all bundles are disabled', () async {
961
+ final bundles = [
962
+ _fingerprintBundle(
963
+ id: '00000000-0000-0000-0000-000000000002',
964
+ fingerprintHash: 'hash1',
965
+ enabled: false,
966
+ ),
967
+ _fingerprintBundle(
968
+ id: '00000000-0000-0000-0000-000000000001',
969
+ fingerprintHash: 'hash1',
970
+ enabled: false,
971
+ ),
972
+ ];
973
+
974
+ final update = await getUpdateInfo(bundles, _fpArgs());
975
+ expect(update, isNull);
976
+ });
977
+
978
+ test('rollback if latest bundle is disabled and no other updates enabled',
979
+ () async {
980
+ final bundles = [
981
+ _fingerprintBundle(
982
+ id: '00000000-0000-0000-0000-000000000002',
983
+ fingerprintHash: 'hash1',
984
+ enabled: false,
985
+ ),
986
+ _fingerprintBundle(
987
+ id: '00000000-0000-0000-0000-000000000001',
988
+ fingerprintHash: 'hash1',
989
+ enabled: false,
990
+ ),
991
+ ];
992
+
993
+ final update = await getUpdateInfo(bundles, _fpArgs());
994
+ expect(update, isNull);
995
+ });
996
+
997
+ test('applies update when same-fingerprint bundle is available', () async {
998
+ final bundles = [
999
+ _fingerprintBundle(
1000
+ id: '00000000-0000-0000-0000-000000000001',
1001
+ fingerprintHash: 'hash1',
1002
+ message: 'hi',
1003
+ ),
1004
+ ];
1005
+
1006
+ final update = await getUpdateInfo(bundles, _fpArgs());
1007
+ expect(update, isNotNull);
1008
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
1009
+ expect(update.shouldForceUpdate, isFalse);
1010
+ expect(update.status, UpdateStatus.update);
1011
+ expect(update.message, 'hi');
1012
+ });
1013
+
1014
+ test('forces rollback if no matching bundle exists for provided bundleId',
1015
+ () async {
1016
+ final update = await getUpdateInfo(
1017
+ [],
1018
+ _appArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
1019
+ );
1020
+ expect(update, isNotNull);
1021
+ expect(update!.id, nilUuid);
1022
+ expect(update.shouldForceUpdate, isTrue);
1023
+ expect(update.status, UpdateStatus.rollback);
1024
+ });
1025
+
1026
+ test('returns null if user is already up-to-date', () async {
1027
+ final bundles = [
1028
+ _fingerprintBundle(
1029
+ id: '00000000-0000-0000-0000-000000000002',
1030
+ fingerprintHash: 'hash1',
1031
+ ),
1032
+ _fingerprintBundle(
1033
+ id: '00000000-0000-0000-0000-000000000001',
1034
+ fingerprintHash: 'hash1',
1035
+ ),
1036
+ ];
1037
+
1038
+ final update = await getUpdateInfo(
1039
+ bundles,
1040
+ _fpArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
1041
+ );
1042
+ expect(update, isNull);
1043
+ });
1044
+
1045
+ test('rollback if previously used bundle no longer exists', () async {
1046
+ final bundles = [
1047
+ _fingerprintBundle(
1048
+ id: '00000000-0000-0000-0000-000000000001',
1049
+ fingerprintHash: 'hash1',
1050
+ ),
1051
+ ];
1052
+
1053
+ final update = await getUpdateInfo(
1054
+ bundles,
1055
+ _fpArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
1056
+ );
1057
+ expect(update, isNotNull);
1058
+ expect(update!.id, '00000000-0000-0000-0000-000000000001');
1059
+ expect(update.shouldForceUpdate, isTrue);
1060
+ expect(update.status, UpdateStatus.rollback);
1061
+ });
1062
+
1063
+ test('selects next available bundle even if shouldForceUpdate is false',
1064
+ () async {
1065
+ final bundles = [
1066
+ _fingerprintBundle(
1067
+ id: '00000000-0000-0000-0000-000000000003',
1068
+ fingerprintHash: 'hash1',
1069
+ ),
1070
+ _fingerprintBundle(
1071
+ id: '00000000-0000-0000-0000-000000000002',
1072
+ fingerprintHash: 'hash1',
1073
+ ),
1074
+ _fingerprintBundle(
1075
+ id: '00000000-0000-0000-0000-000000000001',
1076
+ fingerprintHash: 'hash1',
1077
+ ),
1078
+ ];
1079
+
1080
+ final update = await getUpdateInfo(
1081
+ bundles,
1082
+ _fpArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
1083
+ );
1084
+ expect(update, isNotNull);
1085
+ expect(update!.id, '00000000-0000-0000-0000-000000000003');
1086
+ expect(update.shouldForceUpdate, isFalse);
1087
+ expect(update.status, UpdateStatus.update);
1088
+ });
1089
+
1090
+ test('applies highest available bundle even if fingerprint unchanged',
1091
+ () async {
1092
+ final bundles = [
1093
+ _fingerprintBundle(
1094
+ id: '00000000-0000-0000-0000-000000000005',
1095
+ fingerprintHash: 'hash1',
1096
+ ),
1097
+ _fingerprintBundle(
1098
+ id: '00000000-0000-0000-0000-000000000004',
1099
+ fingerprintHash: 'hash1',
1100
+ ),
1101
+ _fingerprintBundle(
1102
+ id: '00000000-0000-0000-0000-000000000003',
1103
+ fingerprintHash: 'hash1',
1104
+ ),
1105
+ _fingerprintBundle(
1106
+ id: '00000000-0000-0000-0000-000000000002',
1107
+ fingerprintHash: 'hash1',
1108
+ ),
1109
+ _fingerprintBundle(
1110
+ id: '00000000-0000-0000-0000-000000000001',
1111
+ fingerprintHash: 'hash1',
1112
+ ),
1113
+ ];
1114
+
1115
+ final update = await getUpdateInfo(
1116
+ bundles,
1117
+ _fpArgs(bundleId: '00000000-0000-0000-0000-000000000002'),
1118
+ );
1119
+ expect(update, isNotNull);
1120
+ expect(update!.id, '00000000-0000-0000-0000-000000000005');
1121
+ expect(update.shouldForceUpdate, isFalse);
1122
+ expect(update.status, UpdateStatus.update);
1123
+ });
1124
+ });
1125
+
1126
+ // ---------------------------------------------------------------------------
1127
+ // Gradual rollout (app version)
1128
+ // ---------------------------------------------------------------------------
1129
+ group('getUpdateInfo — gradual rollout (app version)', () {
1130
+ test('returns null when rolloutCohortCount is 0', () async {
1131
+ final bundle = _appVersionBundle(
1132
+ id: '00000000-0000-0000-0000-000000000001',
1133
+ targetAppVersion: '1.0',
1134
+ rolloutCohortCount: 0,
1135
+ );
1136
+
1137
+ final update = await getUpdateInfo(
1138
+ [bundle],
1139
+ _appArgs(cohort: '1'),
1140
+ );
1141
+ expect(update, isNull);
1142
+ });
1143
+
1144
+ test('applies update when rolloutCohortCount is 1000', () async {
1145
+ final bundle = _appVersionBundle(
1146
+ id: '00000000-0000-0000-0000-000000000001',
1147
+ targetAppVersion: '1.0',
1148
+ rolloutCohortCount: 1000,
1149
+ );
1150
+
1151
+ final update = await getUpdateInfo(
1152
+ [bundle],
1153
+ _appArgs(cohort: '1'),
1154
+ );
1155
+ expect(update, isNotNull);
1156
+ expect(update!.id, bundle.id);
1157
+ expect(update.shouldForceUpdate, isFalse);
1158
+ expect(update.status, UpdateStatus.update);
1159
+ });
1160
+
1161
+ test('applies update when rolloutCohortCount is null', () async {
1162
+ final bundle = _appVersionBundle(
1163
+ id: '00000000-0000-0000-0000-000000000001',
1164
+ targetAppVersion: '1.0',
1165
+ rolloutCohortCount: null,
1166
+ );
1167
+
1168
+ final update = await getUpdateInfo(
1169
+ [bundle],
1170
+ _appArgs(cohort: '1'),
1171
+ );
1172
+ expect(update, isNotNull);
1173
+ expect(update!.id, bundle.id);
1174
+ expect(update.shouldForceUpdate, isFalse);
1175
+ expect(update.status, UpdateStatus.update);
1176
+ });
1177
+
1178
+ test('excludes custom cohorts from gradual rollout', () async {
1179
+ final bundle = _appVersionBundle(
1180
+ id: '00000000-0000-0000-0000-000000000001',
1181
+ targetAppVersion: '1.0',
1182
+ rolloutCohortCount: 1000,
1183
+ );
1184
+
1185
+ final update = await getUpdateInfo(
1186
+ [bundle],
1187
+ _appArgs(cohort: 'qa-group'),
1188
+ );
1189
+ expect(update, isNull);
1190
+ });
1191
+
1192
+ test('applies update when custom cohort is in targetCohorts', () async {
1193
+ final bundle = _appVersionBundle(
1194
+ id: '00000000-0000-0000-0000-000000000001',
1195
+ targetAppVersion: '1.0',
1196
+ rolloutCohortCount: 200,
1197
+ targetCohorts: ['qa-group'],
1198
+ );
1199
+
1200
+ final update = await getUpdateInfo(
1201
+ [bundle],
1202
+ _appArgs(cohort: 'qa-group'),
1203
+ );
1204
+ expect(update, isNotNull);
1205
+ expect(update!.id, bundle.id);
1206
+ expect(update.shouldForceUpdate, isFalse);
1207
+ expect(update.status, UpdateStatus.update);
1208
+ });
1209
+
1210
+ test('keeps numeric rollout active when targetCohorts are configured',
1211
+ () async {
1212
+ final bundleId = '00000000-0000-0000-0000-000000000022';
1213
+ final eligibleCohort = _findNumericCohort(bundleId, (pos) => pos < 200);
1214
+
1215
+ final bundle = _appVersionBundle(
1216
+ id: bundleId,
1217
+ targetAppVersion: '1.0',
1218
+ rolloutCohortCount: 200,
1219
+ targetCohorts: ['qa-group'],
1220
+ );
1221
+
1222
+ final update = await getUpdateInfo(
1223
+ [bundle],
1224
+ _appArgs(cohort: eligibleCohort),
1225
+ );
1226
+ expect(update, isNotNull);
1227
+ expect(update!.id, bundle.id);
1228
+ expect(update.shouldForceUpdate, isFalse);
1229
+ expect(update.status, UpdateStatus.update);
1230
+ });
1231
+
1232
+ test('includes targeted numeric cohorts outside the rollout set', () async {
1233
+ final bundleId = '00000000-0000-0000-0000-000000000023';
1234
+ final targetedNumericCohort =
1235
+ _findNumericCohort(bundleId, (pos) => pos >= 200);
1236
+
1237
+ final bundle = _appVersionBundle(
1238
+ id: bundleId,
1239
+ targetAppVersion: '1.0',
1240
+ rolloutCohortCount: 200,
1241
+ targetCohorts: [targetedNumericCohort],
1242
+ );
1243
+
1244
+ final update = await getUpdateInfo(
1245
+ [bundle],
1246
+ _appArgs(cohort: targetedNumericCohort),
1247
+ );
1248
+ expect(update, isNotNull);
1249
+ expect(update!.id, bundle.id);
1250
+ expect(update.shouldForceUpdate, isFalse);
1251
+ expect(update.status, UpdateStatus.update);
1252
+ });
1253
+
1254
+ test('returns latest eligible update when newer bundle targets different cohort',
1255
+ () async {
1256
+ final eligibleBundleId = '00000000-0000-0000-0000-000000000020';
1257
+ final blockedBundleId = '00000000-0000-0000-0000-000000000021';
1258
+ final eligibleCohort =
1259
+ _findNumericCohort(eligibleBundleId, (pos) => pos < 200);
1260
+
1261
+ final update = await getUpdateInfo(
1262
+ [
1263
+ _appVersionBundle(
1264
+ id: eligibleBundleId,
1265
+ targetAppVersion: '1.0',
1266
+ rolloutCohortCount: 200,
1267
+ ),
1268
+ _appVersionBundle(
1269
+ id: blockedBundleId,
1270
+ targetAppVersion: '1.0',
1271
+ rolloutCohortCount: 0,
1272
+ targetCohorts: ['qa-group'],
1273
+ ),
1274
+ ],
1275
+ _appArgs(cohort: eligibleCohort),
1276
+ );
1277
+ expect(update, isNotNull);
1278
+ expect(update!.id, eligibleBundleId);
1279
+ expect(update.shouldForceUpdate, isFalse);
1280
+ expect(update.status, UpdateStatus.update);
1281
+ });
1282
+
1283
+ test('re-evaluates current bundle eligibility and rolls back',
1284
+ () async {
1285
+ final previousBundleId = '00000000-0000-0000-0000-000000000020';
1286
+ final currentBundleId = '00000000-0000-0000-0000-000000000021';
1287
+
1288
+ final update = await getUpdateInfo(
1289
+ [
1290
+ _appVersionBundle(
1291
+ id: previousBundleId,
1292
+ targetAppVersion: '1.0',
1293
+ rolloutCohortCount: 1000,
1294
+ ),
1295
+ _appVersionBundle(
1296
+ id: currentBundleId,
1297
+ targetAppVersion: '1.0',
1298
+ rolloutCohortCount: 0,
1299
+ targetCohorts: ['qa-group'],
1300
+ ),
1301
+ ],
1302
+ _appArgs(
1303
+ bundleId: currentBundleId,
1304
+ cohort: '1',
1305
+ ),
1306
+ );
1307
+ expect(update, isNotNull);
1308
+ expect(update!.id, previousBundleId);
1309
+ expect(update.shouldForceUpdate, isTrue);
1310
+ expect(update.status, UpdateStatus.rollback);
1311
+ });
1312
+
1313
+ test('re-evaluates eligibility and falls back to init bundle', () async {
1314
+ final currentBundleId = '00000000-0000-0000-0000-000000000021';
1315
+
1316
+ final update = await getUpdateInfo(
1317
+ [
1318
+ _appVersionBundle(
1319
+ id: currentBundleId,
1320
+ targetAppVersion: '1.0',
1321
+ rolloutCohortCount: 0,
1322
+ targetCohorts: ['qa-group'],
1323
+ ),
1324
+ ],
1325
+ _appArgs(
1326
+ bundleId: currentBundleId,
1327
+ cohort: '1',
1328
+ ),
1329
+ );
1330
+ expect(update, isNotNull);
1331
+ expect(update!.id, nilUuid);
1332
+ expect(update.shouldForceUpdate, isTrue);
1333
+ expect(update.status, UpdateStatus.rollback);
1334
+ });
1335
+
1336
+ test('applies ROLLBACK regardless of rollout settings', () async {
1337
+ final bundle = _appVersionBundle(
1338
+ id: '00000000-0000-0000-0000-000000000001',
1339
+ targetAppVersion: '1.0',
1340
+ rolloutCohortCount: 0,
1341
+ targetCohorts: ['qa-group'],
1342
+ );
1343
+
1344
+ final update = await getUpdateInfo(
1345
+ [bundle],
1346
+ _appArgs(
1347
+ bundleId: '00000000-0000-0000-0000-000000000002',
1348
+ cohort: 'not-targeted',
1349
+ ),
1350
+ );
1351
+ expect(update, isNotNull);
1352
+ expect(update!.id, bundle.id);
1353
+ expect(update.shouldForceUpdate, isTrue);
1354
+ expect(update.status, UpdateStatus.rollback);
1355
+ });
1356
+ });
1357
+
1358
+ // ---------------------------------------------------------------------------
1359
+ // Gradual rollout (fingerprint)
1360
+ // ---------------------------------------------------------------------------
1361
+ group('getUpdateInfo — gradual rollout (fingerprint)', () {
1362
+ test('returns null when rolloutCohortCount is 0', () async {
1363
+ final bundle = _fingerprintBundle(
1364
+ id: '00000000-0000-0000-0000-000000000001',
1365
+ fingerprintHash: 'hash1',
1366
+ rolloutCohortCount: 0,
1367
+ );
1368
+
1369
+ final update = await getUpdateInfo(
1370
+ [bundle],
1371
+ _fpArgs(cohort: '1'),
1372
+ );
1373
+ expect(update, isNull);
1374
+ });
1375
+
1376
+ test('applies update when rolloutCohortCount is 1000', () async {
1377
+ final bundle = _fingerprintBundle(
1378
+ id: '00000000-0000-0000-0000-000000000001',
1379
+ fingerprintHash: 'hash1',
1380
+ rolloutCohortCount: 1000,
1381
+ );
1382
+
1383
+ final update = await getUpdateInfo(
1384
+ [bundle],
1385
+ _fpArgs(cohort: '1'),
1386
+ );
1387
+ expect(update, isNotNull);
1388
+ expect(update!.id, bundle.id);
1389
+ expect(update.shouldForceUpdate, isFalse);
1390
+ expect(update.status, UpdateStatus.update);
1391
+ });
1392
+
1393
+ test('applies update when rolloutCohortCount is null', () async {
1394
+ final bundle = _fingerprintBundle(
1395
+ id: '00000000-0000-0000-0000-000000000001',
1396
+ fingerprintHash: 'hash1',
1397
+ rolloutCohortCount: null,
1398
+ );
1399
+
1400
+ final update = await getUpdateInfo(
1401
+ [bundle],
1402
+ _fpArgs(cohort: '1'),
1403
+ );
1404
+ expect(update, isNotNull);
1405
+ expect(update!.id, bundle.id);
1406
+ expect(update.shouldForceUpdate, isFalse);
1407
+ expect(update.status, UpdateStatus.update);
1408
+ });
1409
+
1410
+ test('excludes custom cohorts from gradual rollout', () async {
1411
+ final bundle = _fingerprintBundle(
1412
+ id: '00000000-0000-0000-0000-000000000001',
1413
+ fingerprintHash: 'hash1',
1414
+ rolloutCohortCount: 1000,
1415
+ );
1416
+
1417
+ final update = await getUpdateInfo(
1418
+ [bundle],
1419
+ _fpArgs(cohort: 'qa-group'),
1420
+ );
1421
+ expect(update, isNull);
1422
+ });
1423
+
1424
+ test('applies update when custom cohort is in targetCohorts', () async {
1425
+ final bundle = _fingerprintBundle(
1426
+ id: '00000000-0000-0000-0000-000000000001',
1427
+ fingerprintHash: 'hash1',
1428
+ rolloutCohortCount: 200,
1429
+ targetCohorts: ['qa-group'],
1430
+ );
1431
+
1432
+ final update = await getUpdateInfo(
1433
+ [bundle],
1434
+ _fpArgs(cohort: 'qa-group'),
1435
+ );
1436
+ expect(update, isNotNull);
1437
+ expect(update!.id, bundle.id);
1438
+ expect(update.shouldForceUpdate, isFalse);
1439
+ expect(update.status, UpdateStatus.update);
1440
+ });
1441
+
1442
+ test('keeps numeric rollout active when targetCohorts are configured',
1443
+ () async {
1444
+ final bundleId = '00000000-0000-0000-0000-000000000022';
1445
+ final eligibleCohort = _findNumericCohort(bundleId, (pos) => pos < 200);
1446
+
1447
+ final bundle = _fingerprintBundle(
1448
+ id: bundleId,
1449
+ fingerprintHash: 'hash1',
1450
+ rolloutCohortCount: 200,
1451
+ targetCohorts: ['qa-group'],
1452
+ );
1453
+
1454
+ final update = await getUpdateInfo(
1455
+ [bundle],
1456
+ _fpArgs(cohort: eligibleCohort),
1457
+ );
1458
+ expect(update, isNotNull);
1459
+ expect(update!.id, bundle.id);
1460
+ expect(update.shouldForceUpdate, isFalse);
1461
+ expect(update.status, UpdateStatus.update);
1462
+ });
1463
+
1464
+ test('includes targeted numeric cohorts outside the rollout set', () async {
1465
+ final bundleId = '00000000-0000-0000-0000-000000000023';
1466
+ final targetedNumericCohort =
1467
+ _findNumericCohort(bundleId, (pos) => pos >= 200);
1468
+
1469
+ final bundle = _fingerprintBundle(
1470
+ id: bundleId,
1471
+ fingerprintHash: 'hash1',
1472
+ rolloutCohortCount: 200,
1473
+ targetCohorts: [targetedNumericCohort],
1474
+ );
1475
+
1476
+ final update = await getUpdateInfo(
1477
+ [bundle],
1478
+ _fpArgs(cohort: targetedNumericCohort),
1479
+ );
1480
+ expect(update, isNotNull);
1481
+ expect(update!.id, bundle.id);
1482
+ expect(update.shouldForceUpdate, isFalse);
1483
+ expect(update.status, UpdateStatus.update);
1484
+ });
1485
+
1486
+ test('returns latest eligible update when newer bundle targets different cohort',
1487
+ () async {
1488
+ final eligibleBundleId = '00000000-0000-0000-0000-000000000020';
1489
+ final blockedBundleId = '00000000-0000-0000-0000-000000000021';
1490
+ final eligibleCohort =
1491
+ _findNumericCohort(eligibleBundleId, (pos) => pos < 200);
1492
+
1493
+ final update = await getUpdateInfo(
1494
+ [
1495
+ _fingerprintBundle(
1496
+ id: eligibleBundleId,
1497
+ fingerprintHash: 'hash1',
1498
+ rolloutCohortCount: 200,
1499
+ ),
1500
+ _fingerprintBundle(
1501
+ id: blockedBundleId,
1502
+ fingerprintHash: 'hash1',
1503
+ rolloutCohortCount: 0,
1504
+ targetCohorts: ['qa-group'],
1505
+ ),
1506
+ ],
1507
+ _fpArgs(cohort: eligibleCohort),
1508
+ );
1509
+ expect(update, isNotNull);
1510
+ expect(update!.id, eligibleBundleId);
1511
+ expect(update.shouldForceUpdate, isFalse);
1512
+ expect(update.status, UpdateStatus.update);
1513
+ });
1514
+
1515
+ test('re-evaluates current bundle eligibility and rolls back', () async {
1516
+ final previousBundleId = '00000000-0000-0000-0000-000000000020';
1517
+ final currentBundleId = '00000000-0000-0000-0000-000000000021';
1518
+
1519
+ final update = await getUpdateInfo(
1520
+ [
1521
+ _fingerprintBundle(
1522
+ id: previousBundleId,
1523
+ fingerprintHash: 'hash1',
1524
+ rolloutCohortCount: 1000,
1525
+ ),
1526
+ _fingerprintBundle(
1527
+ id: currentBundleId,
1528
+ fingerprintHash: 'hash1',
1529
+ rolloutCohortCount: 0,
1530
+ targetCohorts: ['qa-group'],
1531
+ ),
1532
+ ],
1533
+ _fpArgs(
1534
+ bundleId: currentBundleId,
1535
+ cohort: '1',
1536
+ ),
1537
+ );
1538
+ expect(update, isNotNull);
1539
+ expect(update!.id, previousBundleId);
1540
+ expect(update.shouldForceUpdate, isTrue);
1541
+ expect(update.status, UpdateStatus.rollback);
1542
+ });
1543
+
1544
+ test('re-evaluates eligibility and falls back to init bundle', () async {
1545
+ final currentBundleId = '00000000-0000-0000-0000-000000000021';
1546
+
1547
+ final update = await getUpdateInfo(
1548
+ [
1549
+ _fingerprintBundle(
1550
+ id: currentBundleId,
1551
+ fingerprintHash: 'hash1',
1552
+ rolloutCohortCount: 0,
1553
+ targetCohorts: ['qa-group'],
1554
+ ),
1555
+ ],
1556
+ _fpArgs(
1557
+ bundleId: currentBundleId,
1558
+ cohort: '1',
1559
+ ),
1560
+ );
1561
+ expect(update, isNotNull);
1562
+ expect(update!.id, nilUuid);
1563
+ expect(update.shouldForceUpdate, isTrue);
1564
+ expect(update.status, UpdateStatus.rollback);
1565
+ });
1566
+
1567
+ test('applies ROLLBACK regardless of rollout settings', () async {
1568
+ final bundle = _fingerprintBundle(
1569
+ id: '00000000-0000-0000-0000-000000000001',
1570
+ fingerprintHash: 'hash1',
1571
+ rolloutCohortCount: 0,
1572
+ targetCohorts: ['qa-group'],
1573
+ );
1574
+
1575
+ final update = await getUpdateInfo(
1576
+ [bundle],
1577
+ _fpArgs(
1578
+ bundleId: '00000000-0000-0000-0000-000000000002',
1579
+ cohort: 'not-targeted',
1580
+ ),
1581
+ );
1582
+ expect(update, isNotNull);
1583
+ expect(update!.id, bundle.id);
1584
+ expect(update.shouldForceUpdate, isTrue);
1585
+ expect(update.status, UpdateStatus.rollback);
1586
+ });
1587
+ });
1588
+ }
1589
+
1590
+ String _findNumericCohort(
1591
+ String bundleId,
1592
+ bool Function(int position) predicate,
1593
+ ) {
1594
+ for (var cohort = 1; cohort <= numericCohortSize; cohort++) {
1595
+ if (predicate(getNumericCohortRolloutPosition(bundleId, cohort))) {
1596
+ return '$cohort';
1597
+ }
1598
+ }
1599
+ throw StateError('No numeric cohort matched for bundle $bundleId');
1600
+ }