feat: resumable artifact uploads with server-enforced size limit (#3832)

This commit is contained in:
Eric Seidel
2026-06-30 10:50:13 -07:00
committed by GitHub
parent 9570be87ba
commit 1b9b9bf201
10 changed files with 672 additions and 23 deletions
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:shorebird_code_push_client/src/version.dart';
@@ -91,20 +92,36 @@ class CodePushClient {
http.Client? httpClient,
Uri? hostedUri,
Map<String, String>? customHeaders,
@visibleForTesting
Duration uploadRetryBaseDelay = const Duration(seconds: 1),
}) : _httpClient = _CodePushHttpClient(httpClient ?? http.Client(), {
...standardHeaders,
...?customHeaders,
}),
_uploadRetryBaseDelay = uploadRetryBaseDelay,
hostedUri = hostedUri ?? Uri.https('api.shorebird.dev');
/// The standard headers applied to all requests.
@visibleForTesting
static const standardHeaders = <String, String>{'x-version': packageVersion};
/// The default error message to use when an unknown error occurs.
@visibleForTesting
static const unknownErrorMessage = 'An unknown error occurred.';
/// The status GCS returns ("Resume Incomplete") between resumable chunks.
static const _resumeIncompleteStatus = 308;
/// The maximum number of consecutive failures tolerated while uploading a
/// single resumable session before the upload is abandoned.
static const _maxUploadFailures = 5;
final http.Client _httpClient;
/// The base delay for exponential backoff between resumable upload retries.
/// Doubles with each consecutive failure.
final Duration _uploadRetryBaseDelay;
/// The hosted uri for the Shorebird CodePush API.
final Uri hostedUri;
@@ -160,17 +177,12 @@ class CodePushClient {
json.decode(body) as Map<String, dynamic>,
);
final uploadRequest = http.MultipartRequest('POST', Uri.parse(decoded.url))
..files.add(file);
final uploadResponse = await _httpClient.send(uploadRequest);
if (!uploadResponse.isSuccess) {
throw CodePushException(
message:
'''Failed to upload artifact (${uploadResponse.reasonPhrase} '${uploadResponse.statusCode})''',
);
}
await _uploadArtifact(
artifactPath: artifactPath,
multipartFile: file,
url: decoded.url,
uploadMethod: decoded.uploadMethod,
);
}
/// Create a new artifact for a specific [releaseId].
@@ -219,17 +231,152 @@ class CodePushClient {
json.decode(body) as Map<String, dynamic>,
);
final uploadRequest = http.MultipartRequest('POST', Uri.parse(decoded.url))
..files.add(file);
await _uploadArtifact(
artifactPath: artifactPath,
multipartFile: file,
url: decoded.url,
uploadMethod: decoded.uploadMethod,
);
}
final uploadResponse = await _httpClient.send(uploadRequest);
if (!uploadResponse.isSuccess) {
throw CodePushException(
message:
'''Failed to upload artifact (${uploadResponse.reasonPhrase} '${uploadResponse.statusCode})''',
/// Uploads an artifact's bytes to storage using the method the server
/// selected in the create response.
Future<void> _uploadArtifact({
required String artifactPath,
required http.MultipartFile multipartFile,
required String url,
required ArtifactUploadMethod? uploadMethod,
}) async {
if (uploadMethod == ArtifactUploadMethod.resumable) {
await _resumableUpload(
sessionUri: Uri.parse(url),
artifactPath: artifactPath,
);
return;
}
// Legacy single multipart POST. Remove once the server no longer returns
// ArtifactUploadMethod.multipart (i.e. all supported clients are new
// enough to receive a resumable session).
final uploadRequest = http.MultipartRequest('POST', Uri.parse(url))
..files.add(multipartFile);
final uploadResponse = await _httpClient.send(uploadRequest);
if (!uploadResponse.isSuccess) {
throw _uploadFailed(uploadResponse);
}
}
/// Uploads [artifactPath] to a GCS resumable session at [sessionUri] by
/// PUTing the bytes in fixed-size chunks with a `Content-Range` header,
/// resuming from the last byte GCS acknowledged if a chunk fails. The
/// session was initiated (and size-bound) server-side.
Future<void> _resumableUpload({
required Uri sessionUri,
required String artifactPath,
}) async {
// GCS requires chunk sizes to be a multiple of 256 KiB (except the last).
const chunkSize = 8 * 1024 * 1024;
final file = File(artifactPath);
final total = await file.length();
final raf = await file.open();
var failures = 0;
try {
var offset = 0;
while (offset < total) {
final end = offset + chunkSize < total ? offset + chunkSize : total;
await raf.setPosition(offset);
final chunk = await raf.read(end - offset);
final http.StreamedResponse response;
try {
response = await _httpClient.send(
http.Request('PUT', sessionUri)
..bodyBytes = chunk
..headers['content-range'] = 'bytes $offset-${end - 1}/$total',
);
} on Exception {
// Network failure mid-chunk: back off, ask GCS how far it got, and
// resume from there.
if (++failures > _maxUploadFailures) rethrow;
await _backoff(failures);
offset = await _queryResumeOffset(sessionUri, total);
continue;
}
final status = response.statusCode;
await response.stream.drain<void>();
if (status == HttpStatus.ok || status == HttpStatus.created) return;
if (status == _resumeIncompleteStatus) {
// A 308 reports GCS's stored byte count in the `range` header. Its
// absence means GCS has no bytes yet, so we must restart from 0
// (per the resumable upload status-check docs). Treat a lack of
// forward progress as a failure so a stuck session can't spin
// forever.
final next = _parseRangeEnd(response.headers['range']) ?? 0;
if (next > offset) {
failures = 0;
} else if (++failures > _maxUploadFailures) {
throw _uploadFailed(response);
} else {
await _backoff(failures);
}
offset = next;
} else if (status >= HttpStatus.internalServerError) {
// Transient server error (5xx): recover the same way as a mid-chunk
// network failure — back off, then resume from where GCS left off
// rather than re-sending bytes it has already persisted.
if (++failures > _maxUploadFailures) throw _uploadFailed(response);
await _backoff(failures);
offset = await _queryResumeOffset(sessionUri, total);
} else {
throw _uploadFailed(response);
}
}
} finally {
await raf.close();
}
}
/// Waits with exponential backoff before retrying a resumable upload,
/// doubling [_uploadRetryBaseDelay] with each consecutive [failures].
Future<void> _backoff(int failures) =>
Future<void>.delayed(_uploadRetryBaseDelay * (1 << (failures - 1)));
/// Queries a resumable [sessionUri] for the number of bytes GCS has received
/// so far, returning the offset to resume from.
Future<int> _queryResumeOffset(Uri sessionUri, int total) async {
final response = await _httpClient.send(
http.Request('PUT', sessionUri)
..headers['content-range'] = 'bytes */$total',
);
final status = response.statusCode;
await response.stream.drain<void>();
if (status == HttpStatus.ok || status == HttpStatus.created) return total;
if (status == _resumeIncompleteStatus) {
return _parseRangeEnd(response.headers['range']) ?? 0;
}
throw _uploadFailed(response);
}
/// Parses the next byte offset from a GCS `Range: bytes=0-X` header,
/// returning `X + 1`, or null if the header is absent/malformed.
int? _parseRangeEnd(String? range) {
if (range == null) return null;
final dash = range.lastIndexOf('-');
if (dash == -1) return null;
final last = int.tryParse(range.substring(dash + 1));
return last == null ? null : last + 1;
}
/// The exception thrown when an artifact upload request fails.
CodePushException _uploadFailed(http.BaseResponse response) {
final reason = response.reasonPhrase;
return CodePushException(
message: 'Failed to upload artifact ($reason ${response.statusCode})',
);
}
/// Create a new app with the provided [displayName].
@@ -12,6 +12,7 @@ environment:
dependencies:
collection: ^1.18.0
http: ^1.5.0
meta: ^1.18.3
path: ^1.9.0
shorebird_code_push_protocol:
path: ../shorebird_code_push_protocol
@@ -47,6 +47,8 @@ void main() {
codePushClient = CodePushClient(
httpClient: httpClient,
customHeaders: customHeaders,
// Disable backoff delays so retry paths run instantly under test.
uploadRetryBaseDelay: Duration.zero,
);
when(() => httpClient.send(any())).thenAnswer(
(_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
@@ -456,6 +458,62 @@ void main() {
),
);
});
test('uploads via a resumable session when the server '
'selects resumable', () async {
const artifactId = 42;
const sessionUrl = 'https://storage.googleapis.com/session?upload_id=a';
final responses = [
http.StreamedResponse(
Stream.value(
utf8.encode(
json.encode(
const CreatePatchArtifactResponse(
id: artifactId,
patchId: patchId,
arch: arch,
platform: platform,
hash: hash,
size: size,
url: sessionUrl,
uploadMethod: ArtifactUploadMethod.resumable,
),
),
),
),
HttpStatus.ok,
),
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
];
when(
() => httpClient.send(any()),
).thenAnswer((_) async => responses.removeAt(0));
final tempDir = Directory.systemTemp.createTempSync();
final fixture = File(path.join(tempDir.path, 'patch.txt'))
..writeAsBytesSync([1, 2, 3, 4, 5]);
await expectLater(
codePushClient.createPatchArtifact(
appId: appId,
artifactPath: fixture.path,
patchId: patchId,
arch: arch,
platform: platform,
hash: hash,
),
completes,
);
final requests = verify(
() => httpClient.send(captureAny()),
).captured.cast<http.BaseRequest>();
expect(requests, hasLength(2));
final uploadRequest = requests.last;
expect(uploadRequest.method, equals('PUT'));
expect(uploadRequest.url, equals(Uri.parse(sessionUrl)));
expect(uploadRequest.headers['content-range'], equals('bytes 0-4/5'));
});
});
group('createReleaseArtifact', () {
@@ -803,6 +861,314 @@ void main() {
),
);
});
test('uploads via a resumable session when the server '
'selects resumable', () async {
const artifactId = 42;
const sessionUrl = 'https://storage.googleapis.com/session?upload_id=a';
final responses = [
http.StreamedResponse(
Stream.value(
utf8.encode(
json.encode(
const CreateReleaseArtifactResponse(
id: artifactId,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
size: size,
url: sessionUrl,
uploadMethod: ArtifactUploadMethod.resumable,
),
),
),
),
HttpStatus.ok,
),
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
];
when(
() => httpClient.send(any()),
).thenAnswer((_) async => responses.removeAt(0));
final tempDir = Directory.systemTemp.createTempSync();
final fixture = File(path.join(tempDir.path, 'release.txt'))
..writeAsBytesSync([1, 2, 3, 4, 5]);
await expectLater(
codePushClient.createReleaseArtifact(
appId: appId,
artifactPath: fixture.path,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
canSideload: canSideload,
podfileLockHash: podfileLockHash,
),
completes,
);
final requests = verify(
() => httpClient.send(captureAny()),
).captured.cast<http.BaseRequest>();
expect(requests, hasLength(2));
final uploadRequest = requests.last;
expect(uploadRequest.method, equals('PUT'));
expect(uploadRequest.url, equals(Uri.parse(sessionUrl)));
expect(uploadRequest.headers['content-range'], equals('bytes 0-4/5'));
});
group('resumable upload driver', () {
const sessionUrl = 'https://storage.googleapis.com/session?id=abc';
http.StreamedResponse resumableMeta() => http.StreamedResponse(
Stream.value(
utf8.encode(
json.encode(
const CreateReleaseArtifactResponse(
id: 42,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
size: size,
url: sessionUrl,
uploadMethod: ArtifactUploadMethod.resumable,
),
),
),
),
HttpStatus.ok,
);
String fiveByteFixture() {
final dir = Directory.systemTemp.createTempSync();
return (File(
path.join(dir.path, 'release.txt'),
)..writeAsBytesSync([1, 2, 3, 4, 5])).path;
}
void stubActions(
List<Future<http.StreamedResponse> Function()> actions,
) {
when(
() => httpClient.send(any()),
).thenAnswer((_) => actions.removeAt(0)());
}
Future<void> upload(String artifactPath) =>
codePushClient.createReleaseArtifact(
appId: appId,
artifactPath: artifactPath,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
canSideload: canSideload,
podfileLockHash: null,
);
List<http.BaseRequest> capturedPuts() =>
verify(
() => httpClient.send(captureAny()),
).captured
.cast<http.BaseRequest>()
.where((r) => r.method == 'PUT')
.toList();
test('continues to the next chunk on a 308 response', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-2'},
),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
expect(puts, hasLength(2));
expect(puts[0].headers['content-range'], 'bytes 0-4/5');
expect(puts[1].headers['content-range'], 'bytes 3-4/5');
});
test('throws when a chunk returns an error status', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.badRequest,
),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(
isA<CodePushException>().having(
(e) => e.message,
'message',
contains('Failed to upload artifact'),
),
),
);
});
test(
'queries the offset and resumes after a network failure',
() async {
stubActions([
() async => resumableMeta(),
() async => throw http.ClientException('connection reset'),
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-1'},
),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
// failed chunk, status query, resumed chunk from offset 2.
expect(puts, hasLength(3));
expect(puts[1].headers['content-range'], 'bytes */5');
expect(puts[2].headers['content-range'], 'bytes 2-4/5');
},
);
test('throws when the status query returns an error', () async {
stubActions([
() async => resumableMeta(),
() async => throw http.ClientException('connection reset'),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.badRequest,
),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
test('restarts from the beginning on a 308 with no range', () async {
stubActions([
() async => resumableMeta(),
// 308 without a `range` header: GCS stored nothing, restart at 0.
() async => http.StreamedResponse(const Stream.empty(), 308),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
expect(puts, hasLength(2));
expect(puts[0].headers['content-range'], 'bytes 0-4/5');
// Restarts from offset 0 rather than advancing.
expect(puts[1].headers['content-range'], 'bytes 0-4/5');
});
test('throws when a 308 never makes forward progress', () async {
// A session stuck at offset 0 keeps returning a 308 with no range;
// the upload must give up rather than spin forever.
stubActions([
() async => resumableMeta(),
for (var i = 0; i < 6; i++)
() async => http.StreamedResponse(const Stream.empty(), 308),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
test('queries the offset and resumes after a 5xx', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-1'},
),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
// failed chunk, status query, resumed chunk from offset 2.
expect(puts, hasLength(3));
expect(puts[0].headers['content-range'], 'bytes 0-4/5');
expect(puts[1].headers['content-range'], 'bytes */5');
expect(puts[2].headers['content-range'], 'bytes 2-4/5');
});
test('throws when a 5xx persists through the status query', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
// The status query also fails, so we surface the error.
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
test('throws after exhausting retries on repeated 5xx', () async {
// Each chunk PUT fails with a 5xx while the status query reports no
// forward progress, so failures accumulate until we give up.
final actions = <Future<http.StreamedResponse> Function()>[
() async => resumableMeta(),
];
for (var i = 0; i < 6; i++) {
actions
..add(
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
)
..add(
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-1'},
),
);
}
stubActions(actions);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
});
});
group('createApp', () {
@@ -48,6 +48,7 @@ export 'package:shorebird_code_push_protocol/src/models/active_hour_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/app.dart';
export 'package:shorebird_code_push_protocol/src/models/app_collaborator_role.dart';
export 'package:shorebird_code_push_protocol/src/models/app_metadata.dart';
export 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart';
export 'package:shorebird_code_push_protocol/src/models/channel.dart';
export 'package:shorebird_code_push_protocol/src/models/get_app_patch_downloads_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_app_patch_downloads_parameter3.dart';
@@ -1,5 +1,6 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart';
import 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
/// {@template create_patch_artifact_response}
@@ -16,6 +17,7 @@ class CreatePatchArtifactResponse {
required this.hash,
required this.size,
required this.url,
this.uploadMethod,
});
/// Converts a `Map<String, dynamic>` to a [CreatePatchArtifactResponse].
@@ -31,6 +33,9 @@ class CreatePatchArtifactResponse {
hash: json['hash'] as String,
size: json['size'] as int,
url: json['url'] as String,
uploadMethod: ArtifactUploadMethod.maybeFromJson(
json['upload_method'] as String?,
),
),
);
}
@@ -64,9 +69,16 @@ class CreatePatchArtifactResponse {
/// The size of the artifact in bytes.
final int size;
/// The upload URL for the artifact.
/// The upload URL for the artifact (a signed URL for [ArtifactUploadMethod
/// .multipart], or a resumable session URI for [ArtifactUploadMethod
/// .resumable]).
final String url;
/// How the client should upload the artifact bytes to [url]. Null on
/// responses from older servers, which always implied
/// [ArtifactUploadMethod.multipart].
final ArtifactUploadMethod? uploadMethod;
/// Converts a [CreatePatchArtifactResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
@@ -77,6 +89,7 @@ class CreatePatchArtifactResponse {
'hash': hash,
'size': size,
'url': url,
'upload_method': uploadMethod?.toJson(),
};
}
@@ -89,6 +102,7 @@ class CreatePatchArtifactResponse {
hash,
size,
url,
uploadMethod,
]);
@override
@@ -101,6 +115,7 @@ class CreatePatchArtifactResponse {
platform == other.platform &&
hash == other.hash &&
size == other.size &&
url == other.url;
url == other.url &&
uploadMethod == other.uploadMethod;
}
}
@@ -1,5 +1,6 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart';
import 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
/// {@template create_release_artifact_response}
@@ -16,6 +17,7 @@ class CreateReleaseArtifactResponse {
required this.hash,
required this.size,
required this.url,
this.uploadMethod,
});
/// Converts a `Map<String, dynamic>` to a [CreateReleaseArtifactResponse].
@@ -31,6 +33,9 @@ class CreateReleaseArtifactResponse {
hash: json['hash'] as String,
size: json['size'] as int,
url: json['url'] as String,
uploadMethod: ArtifactUploadMethod.maybeFromJson(
json['upload_method'] as String?,
),
),
);
}
@@ -64,9 +69,16 @@ class CreateReleaseArtifactResponse {
/// The size of the artifact in bytes.
final int size;
/// The upload URL for the artifact.
/// The upload URL for the artifact (a signed URL for [ArtifactUploadMethod
/// .multipart], or a resumable session URI for [ArtifactUploadMethod
/// .resumable]).
final String url;
/// How the client should upload the artifact bytes to [url]. Null on
/// responses from older servers, which always implied
/// [ArtifactUploadMethod.multipart].
final ArtifactUploadMethod? uploadMethod;
/// Converts a [CreateReleaseArtifactResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
@@ -77,6 +89,7 @@ class CreateReleaseArtifactResponse {
'hash': hash,
'size': size,
'url': url,
'upload_method': uploadMethod?.toJson(),
};
}
@@ -89,6 +102,7 @@ class CreateReleaseArtifactResponse {
hash,
size,
url,
uploadMethod,
]);
@override
@@ -101,6 +115,7 @@ class CreateReleaseArtifactResponse {
platform == other.platform &&
hash == other.hash &&
size == other.size &&
url == other.url;
url == other.url &&
uploadMethod == other.uploadMethod;
}
}
@@ -0,0 +1,44 @@
/// How a client should upload an artifact's bytes to storage.
///
/// Returned by the create-artifact endpoints so the client knows how to use the
/// `url` it was handed. Absent (null) on responses from older servers, which
/// always implied [multipart].
enum ArtifactUploadMethod {
/// Legacy single `multipart/form-data` POST of the file to a signed URL.
multipart._('multipart'),
/// Resumable upload: PUT the bytes (chunked, with `Content-Range`) to a
/// server-initiated GCS resumable session URI given in `url`. The session is
/// size-bound at initiation, so GCS rejects an oversized upload.
resumable._('resumable');
const ArtifactUploadMethod._(this.value);
/// Creates an [ArtifactUploadMethod] from a json value.
factory ArtifactUploadMethod.fromJson(String json) {
return ArtifactUploadMethod.values.firstWhere(
(value) => value.value == json,
orElse: () =>
throw FormatException('Unknown ArtifactUploadMethod value: $json'),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static ArtifactUploadMethod? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return ArtifactUploadMethod.fromJson(json);
}
/// The wire value of the enum, used for network transport.
final String value;
/// Converts the enum to its json value.
String toJson() => value;
/// Returns the string form of the enum.
@override
String toString() => value;
}
@@ -18,5 +18,21 @@ void main() {
equals(request.toJson()),
);
});
test('can be (de)serialized with a resumable upload method', () {
const response = CreatePatchArtifactResponse(
id: 42,
patchId: 1,
arch: 'arm64',
platform: ReleasePlatform.android,
hash: '1234',
size: 9876,
url: 'https://example.com',
uploadMethod: ArtifactUploadMethod.resumable,
);
final decoded = CreatePatchArtifactResponse.fromJson(response.toJson());
expect(decoded.toJson(), equals(response.toJson()));
expect(decoded.uploadMethod, equals(ArtifactUploadMethod.resumable));
});
});
}
@@ -18,5 +18,21 @@ void main() {
equals(response.toJson()),
);
});
test('can be (de)serialized with a resumable upload method', () {
const response = CreateReleaseArtifactResponse(
id: 42,
releaseId: 1,
arch: 'arm64',
platform: ReleasePlatform.android,
hash: '1234',
size: 9876,
url: 'https://example.com',
uploadMethod: ArtifactUploadMethod.resumable,
);
final decoded = CreateReleaseArtifactResponse.fromJson(response.toJson());
expect(decoded.toJson(), equals(response.toJson()));
expect(decoded.uploadMethod, equals(ArtifactUploadMethod.resumable));
});
});
}
@@ -0,0 +1,28 @@
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group(ArtifactUploadMethod, () {
test('round-trips through json', () {
for (final method in ArtifactUploadMethod.values) {
expect(ArtifactUploadMethod.fromJson(method.toJson()), equals(method));
}
});
test('maybeFromJson returns null for null', () {
expect(ArtifactUploadMethod.maybeFromJson(null), isNull);
});
test('fromJson throws on an unknown value', () {
expect(
() => ArtifactUploadMethod.fromJson('nope'),
throwsFormatException,
);
});
test('toString returns the wire value', () {
expect(ArtifactUploadMethod.multipart.toString(), 'multipart');
expect(ArtifactUploadMethod.resumable.toString(), 'resumable');
});
});
}