Skip to content

Commit 3f86b9a

Browse files
brianquinlangemini-code-assist[bot]natebosch
authored
test(storage): add support for Storage TestBench (#210)
The Google Cloud Storage team provides a project called "Storage Testbench" that is used for failure testing in Google Cloud Storage clients. Storage Testbench can be run using docker and provides a partial GCS implementation that can be scripted to produce failures. This PR adds the ability to create tests that target Storage Testbench and a trivial test to prove that it works. - Creates a new test tag `storage-testbench` - Adds a new GitHub workflow that starts the docker image and runs tests with that tag - Adds test utilities to work with Storage Testbench - Documents how to run Storage Testbench locally --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Nate Bosch <nbosch1@gmail.com>
1 parent 653dcec commit 3f86b9a

File tree

7 files changed

+177
-28
lines changed

7 files changed

+177
-28
lines changed

.github/workflows/dart_checks.yaml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ jobs:
4343
- name: Format
4444
run: dart format --output=none --set-exit-if-changed .
4545

46-
tests:
47-
name: Tests
46+
unit_tests:
47+
name: Unit Tests
4848
runs-on: ubuntu-latest
4949
strategy:
5050
matrix:
@@ -57,6 +57,18 @@ jobs:
5757
- run: dart pub get
5858
- run: dart test -p ${{ matrix.platform }} .
5959

60+
storage_testbench_tests:
61+
name: Storage Testbench Tests
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
65+
- uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c
66+
with:
67+
sdk: ${{ env.DART_VERSION }}
68+
- run: dart pub get
69+
- run: docker run -d --rm -p 9000:9000 -p 8888:8888 gcr.io/cloud-devrel-public-resources/storage-testbench:latest
70+
- run: dart test -P storage-testbench .
71+
6072
generator:
6173
name: Generators
6274
runs-on: ubuntu-latest

DEVELOPER_GUIDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ To run these tests locally (they are automatically run for PRs using
5959
* **Missing Scopes:** If a test fails with `insufficient_scope`, check the error message for the required scope URL and re-authenticate with that scope.
6060
* **Disabled APIs:** If a test fails with a `ForbiddenException` stating an API has not been used, follow the URL in the error message to enable the API in the Google Cloud Console.
6161

62+
### Running against Storage Testbench
63+
64+
Some integration tests in `package:google_cloud_storage` use the
65+
[Storage Testbench][]. These tests are tagged with
66+
`@Tags(['storage-testbench'])` and are not run by default, i.e., `dart test`
67+
will not run them.
68+
69+
To run these tests locally (they are automatically run for PRs using a
70+
[GitHub workflow](.github/workflows/dart_checks.yaml)):
71+
72+
1. **Start Storage Testbench:**
73+
```bash
74+
$ docker run -d --rm -p 9000:9000 -p 8888:8888 \
75+
gcr.io/cloud-devrel-public-resources/storage-testbench:latest
76+
```
77+
78+
2. **Run the tests:**
79+
```bash
80+
$ dart test . -P storage-testbench
81+
```
82+
6283
## Pull Requests
6384

6485
* PRs should follow [Conventional Commits][]
@@ -131,3 +152,4 @@ go run github.com/googleapis/librarian/cmd/librarian@main update conformance goo
131152

132153
[Google Cloud Console]: https://console.cloud.google.com/
133154
[Conventional Commits]: https://www.conventionalcommits.org/
155+
[Storage Testbench]: https://github.com/googleapis/storage-testbench

dart_test.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ concurrency: 1
2121
tags:
2222
google-cloud:
2323
firebase-emulator:
24-
exclude_tags: google-cloud || firebase-emulator
24+
storage-testbench:
25+
exclude_tags: google-cloud || firebase-emulator || storage-testbench
2526

2627
presets:
2728
google-cloud:
@@ -32,3 +33,6 @@ presets:
3233
platforms: [vm]
3334
firebase-emulator:
3435
include_tags: firebase-emulator
36+
storage-testbench:
37+
include_tags: storage-testbench
38+
platforms: [vm]

generated/google_cloud_rpc/lib/src/exceptions.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,14 @@ final class ServiceException implements Exception {
201201
}
202202

203203
final Status status;
204-
if (json is Map<String, dynamic> && json['error'] is Map<String, dynamic>) {
205-
status = Status.fromJson(json['error'] as Map<String, dynamic>);
204+
// The Storage Testbench sometimes returns non-conformant `Status` responses
205+
// with an object for the `'message'` field.
206+
// See https://github.com/googleapis/storage-testbench
207+
if (json case {'error': final Map<String, Object?> error}
208+
when error['code'] is int? &&
209+
error['message'] is String? &&
210+
error['details'] is List<Object?>?) {
211+
status = Status.fromJson(error);
206212
} else {
207213
return ServiceException._fromDecodedResponse(
208214
responseBody,

pkgs/google_cloud_storage/dart_test.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ concurrency: 1
2121
tags:
2222
google-cloud:
2323
firebase-emulator:
24-
exclude_tags: google-cloud || firebase-emulator
24+
storage-testbench:
25+
exclude_tags: google-cloud || firebase-emulator || storage-testbench
2526

2627
presets:
2728
google-cloud:
@@ -32,3 +33,6 @@ presets:
3233
platforms: [vm]
3334
firebase-emulator:
3435
include_tags: firebase-emulator
36+
storage-testbench:
37+
include_tags: storage-testbench
38+
platforms: [vm]

pkgs/google_cloud_storage/test/download_object_test.dart

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
@TestOn('vm')
16-
library;
17-
1815
import 'dart:convert';
1916
import 'dart:io';
2017
import 'dart:typed_data';
@@ -28,8 +25,54 @@ import 'test_utils.dart';
2825

2926
void main() async {
3027
late Storage storage;
28+
late RetryTestHttpClient client;
29+
late RetryTestCreator retryCreator;
3130

3231
group('download object', () {
32+
group('storage-testbench', tags: ['storage-testbench'], () {
33+
setUp(() async {
34+
client = RetryTestHttpClient(http.Client());
35+
storage = Storage(
36+
projectId: 'test-project',
37+
apiEndpoint: 'localhost:9000',
38+
useAuthWithCustomEndpoint: false,
39+
client: client,
40+
);
41+
retryCreator = RetryTestCreator(http.Client());
42+
});
43+
44+
tearDown(() async {
45+
await retryCreator.close();
46+
storage.close();
47+
});
48+
49+
test('two 503s and then success', () async {
50+
final id = await retryCreator.createRetryTest({
51+
'instructions': {
52+
'storage.objects.get': ['return-503', 'return-503'],
53+
},
54+
'transport': 'HTTP',
55+
});
56+
57+
final bucketName = await createBucketWithTearDown(
58+
storage,
59+
'dl_obj_empty',
60+
);
61+
62+
await storage.uploadObject(
63+
bucketName,
64+
'object1',
65+
[],
66+
ifGenerationMatch: BigInt.zero,
67+
);
68+
69+
client.retryTestId = id;
70+
final data = await storage.downloadObject(bucketName, 'object1');
71+
client.retryTestId = null;
72+
expect(data, isEmpty);
73+
});
74+
});
75+
3376
group('google-cloud', tags: ['google-cloud'], () {
3477
setUp(() async {
3578
storage = Storage();
@@ -223,27 +266,26 @@ void main() async {
223266
);
224267
});
225268
});
269+
});
226270

227-
test('hash failure', () async {
228-
var count = 0;
229-
final mockClient = MockClient((request) async {
230-
count++;
231-
final headers = {'content-type': 'text/plain; charset=UTF-8'};
232-
if (count == 1) {
233-
headers['x-goog-hash'] = 'crc32c=/BAD';
234-
} else if (count == 2) {
235-
headers['x-goog-hash'] = 'md5=/BAD';
236-
} else {
237-
headers['x-goog-hash'] =
238-
'crc32c=/mzx3A==,md5=7Qdih1MuhjZehB6Sv8UNjA==';
239-
}
240-
return http.Response('Hello World!', 200, headers: headers);
241-
});
271+
test('hash failure', () async {
272+
var count = 0;
273+
final mockClient = MockClient((request) async {
274+
count++;
275+
final headers = {'content-type': 'text/plain; charset=UTF-8'};
276+
if (count == 1) {
277+
headers['x-goog-hash'] = 'crc32c=/BAD';
278+
} else if (count == 2) {
279+
headers['x-goog-hash'] = 'md5=/BAD';
280+
} else {
281+
headers['x-goog-hash'] = 'crc32c=/mzx3A==,md5=7Qdih1MuhjZehB6Sv8UNjA==';
282+
}
283+
return http.Response('Hello World!', 200, headers: headers);
284+
});
242285

243-
final storage = Storage(client: mockClient, projectId: 'fake project');
286+
final storage = Storage(client: mockClient, projectId: 'fake project');
244287

245-
final actualData = await storage.downloadObject('bucket', 'object');
246-
expect(actualData, utf8.encode('Hello World!'));
247-
});
288+
final actualData = await storage.downloadObject('bucket', 'object');
289+
expect(actualData, utf8.encode('Hello World!'));
248290
});
249291
}

pkgs/google_cloud_storage/test/test_utils.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import 'dart:convert';
1516
import 'dart:math';
1617

1718
import 'package:google_cloud_storage/google_cloud_storage.dart';
19+
import 'package:http/http.dart' as http;
1820
import 'package:test/test.dart';
1921

2022
const _bucketChars = 'abcdefghijklmnopqrstuvwxyz0123456789';
@@ -79,3 +81,60 @@ Future<String> createBucketWithTearDown(
7981
);
8082
return bucketName;
8183
}
84+
85+
/// An HTTP client that can add a `x-retry-test-id` header to requests for
86+
/// testing with Storage Testbench.
87+
///
88+
/// See https://github.com/googleapis/storage-testbench.
89+
final class RetryTestHttpClient extends http.BaseClient {
90+
final http.Client _client;
91+
String? retryTestId;
92+
93+
RetryTestHttpClient(this._client);
94+
95+
@override
96+
Future<http.StreamedResponse> send(http.BaseRequest originalRequest) {
97+
if (retryTestId case final id?) {
98+
originalRequest.headers['x-retry-test-id'] = id;
99+
}
100+
return _client.send(originalRequest);
101+
}
102+
103+
@override
104+
void close() => _client.close();
105+
}
106+
107+
/// A client that can create Storage Testbench Retry Tests.
108+
///
109+
/// See https://github.com/googleapis/storage-testbench?tab=readme-ov-file#creating-a-new-retry-test
110+
final class RetryTestCreator {
111+
final http.Client _client;
112+
final List<String> _retryTests = [];
113+
114+
RetryTestCreator(this._client);
115+
116+
/// Creates a new retry test and returns the test id that can be used in the
117+
/// `x-retry-test-id` header.
118+
///
119+
/// The [test] object is a JSON-serializable object that describes the retry
120+
/// test. See
121+
/// https://github.com/googleapis/storage-testbench?tab=readme-ov-file#creating-a-new-retry-test
122+
Future<String> createRetryTest(Object test) async {
123+
final responseBody = (await _client.post(
124+
Uri.http('localhost:9000', '/retry_test'),
125+
headers: {'Content-Type': 'application/json'},
126+
body: jsonEncode(test),
127+
)).body;
128+
final id =
129+
(jsonDecode(responseBody) as Map<String, dynamic>)['id'] as String;
130+
_retryTests.add(id);
131+
return id;
132+
}
133+
134+
Future<void> close() async {
135+
for (var id in _retryTests) {
136+
await _client.delete(Uri.http('localhost:9000', '/retry_test/$id'));
137+
}
138+
_client.close();
139+
}
140+
}

0 commit comments

Comments
 (0)