@_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.
- package/dart-src/packages/cli-tools/.dart_tool/package_config.json +496 -0
- package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +743 -0
- package/dart-src/packages/cli-tools/lib/src/cli_base.dart +7 -6
- package/dart-src/packages/cli-tools/lib/src/commands/build.dart +46 -49
- package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +44 -23
- package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +27 -17
- package/dart-src/packages/cli-tools/lib/src/commands/console.dart +11 -6
- package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +39 -22
- package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -13
- package/dart-src/packages/cli-tools/lib/src/commands/init.dart +6 -5
- package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +15 -9
- package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +14 -8
- package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +37 -12
- package/dart-src/packages/cli-tools/lib/src/operations.dart +45 -0
- package/dart-src/packages/cli-tools/lib/src/pack.dart +9 -0
- package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +234 -0
- package/dart-src/packages/cli-tools/pubspec.lock +638 -0
- package/dart-src/packages/cli-tools/test/backends_test.dart +207 -0
- package/dart-src/packages/cli-tools/test/build_pack_test.dart +101 -0
- package/dart-src/packages/cli-tools/test/cli_test.dart +442 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +83 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +256 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +347 -0
- package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +315 -0
- package/dart-src/packages/core/test/rollout_test.dart +128 -0
- package/dart-src/packages/core/test/semver_test.dart +52 -0
- package/dart-src/packages/core/test/semver_vectors.json +1 -0
- package/dart-src/packages/core/test/types_test.dart +129 -0
- package/dart-src/packages/core/test/uuid_test.dart +38 -0
- package/dart-src/plugins/aws/test/aws_database_test.dart +167 -0
- package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +190 -0
- package/dart-src/plugins/aws/test/aws_storage_test.dart +73 -0
- package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +122 -0
- package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +106 -0
- package/dart-src/plugins/cloudflare/test/d1_database_test.dart +190 -0
- package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +256 -0
- package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +77 -0
- package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +166 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +74 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +332 -0
- package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +711 -0
- package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +294 -0
- package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +192 -0
- package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +51 -0
- package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +1600 -0
- package/dart-src/plugins/plugin-core/test/helpers_test.dart +259 -0
- package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +139 -0
- package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +86 -0
- package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +77 -0
- package/dart-src/plugins/postgres/test/mock_postgres_client.dart +297 -0
- package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +140 -0
- package/dart-src/plugins/postgres/test/postgres_database_test.dart +181 -0
- package/dart-src/plugins/standalone/test/standalone_test.dart +264 -0
- package/dart-src/plugins/supabase/test/mock_supabase_client.dart +654 -0
- package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +112 -0
- package/dart-src/plugins/supabase/test/supabase_database_test.dart +207 -0
- package/dart-src/plugins/supabase/test/supabase_storage_test.dart +175 -0
- package/package.json +1 -1
- 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
|
+
}
|