diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bad49d7612bd..322cebb17a5f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -28,7 +28,18 @@ This project contains BDD tests. __Never__ run the test suite against an organization with production data. Tests can be executed via [`cucumber`](https://cucumber.io/docs/guides/). -You can optionally add `features/v/.feature:` to filter individual tests. + +Generated SDKs include the language-neutral request plans and replay server +under `features/generated`. Run them without an API spec checkout or generator +tooling: + +```shell +./run-bdd-tests.sh +``` + +The Ruby Cucumber runner still invokes the generated SDK method and validates +its response. Request bodies and parameters come from the generated scenario +plan, while recorded HTTP interactions are replayed by the shared server. By default integration tests use recorded API responses stored in cassettes. To record new API responses run the tests with `RECORD=true`. To run integration tests against API without recording cassettes, run the tests with `RECORD=none`. @@ -39,4 +50,4 @@ generated code, only commit test files being updated and any updated cassettes. ### CI In rare ocassions, the test suite may fail due to a corrupted cache in `ruby/setup-ruby` action. -Please update `CACHE_VERSION` secret in https://github.com/DataDog/datadog-api-client-ruby/settings/secrets/actions/CACHE_VERSION with a new unique value. \ No newline at end of file +Please update `CACHE_VERSION` secret in https://github.com/DataDog/datadog-api-client-ruby/settings/secrets/actions/CACHE_VERSION with a new unique value. diff --git a/features/generated/test-runner-data/manifest.json b/features/generated/test-runner-data/manifest.json new file mode 100644 index 000000000000..52da3d497a44 --- /dev/null +++ b/features/generated/test-runner-data/manifest.json @@ -0,0 +1,125 @@ +{ + "scenarios": [ + { + "feature": "Action Connection", + "file": "v2/action-connection/create-a-new-action-connection-returns-bad-request-response.json", + "scenario": "Create a new Action Connection returns \"Bad Request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/create-a-new-action-connection-returns-successfully-created-action-connection-response.json", + "scenario": "Create a new Action Connection returns \"Successfully created Action Connection\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/delete-an-existing-action-connection-returns-not-found-response.json", + "scenario": "Delete an existing Action Connection returns \"Not Found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/delete-an-existing-action-connection-returns-the-resource-was-deleted-successfully-response.json", + "scenario": "Delete an existing Action Connection returns \"The resource was deleted successfully.\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/get-an-existing-action-connection-returns-bad-request-response.json", + "scenario": "Get an existing Action Connection returns \"Bad Request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/get-an-existing-action-connection-returns-not-found-response.json", + "scenario": "Get an existing Action Connection returns \"Not Found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/get-an-existing-action-connection-returns-successfully-get-action-connection-response.json", + "scenario": "Get an existing Action Connection returns \"Successfully get Action Connection\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/get-an-existing-app-key-registration-returns-bad-request-response.json", + "scenario": "Get an existing App Key Registration returns \"Bad request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/get-an-existing-app-key-registration-returns-not-found-response.json", + "scenario": "Get an existing App Key Registration returns \"Not found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/get-an-existing-app-key-registration-returns-ok-response.json", + "scenario": "Get an existing App Key Registration returns \"OK\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/list-app-key-registrations-returns-bad-request-response.json", + "scenario": "List App Key Registrations returns \"Bad request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/list-app-key-registrations-returns-ok-response.json", + "scenario": "List App Key Registrations returns \"OK\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/register-a-new-app-key-returns-bad-request-response.json", + "scenario": "Register a new App Key returns \"Bad request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/register-a-new-app-key-returns-created-response.json", + "scenario": "Register a new App Key returns \"Created\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/unregister-an-app-key-returns-bad-request-response.json", + "scenario": "Unregister an App Key returns \"Bad request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/unregister-an-app-key-returns-no-content-response.json", + "scenario": "Unregister an App Key returns \"No Content\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/unregister-an-app-key-returns-not-found-response.json", + "scenario": "Unregister an App Key returns \"Not found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/update-an-existing-action-connection-returns-bad-request-response.json", + "scenario": "Update an existing Action Connection returns \"Bad Request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/update-an-existing-action-connection-returns-not-found-response.json", + "scenario": "Update an existing Action Connection returns \"Not Found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "file": "v2/action-connection/update-an-existing-action-connection-returns-successfully-updated-action-connection-response.json", + "scenario": "Update an existing Action Connection returns \"Successfully updated Action Connection\" response", + "version": "v2" + } + ], + "schema_version": 1 +} diff --git a/features/generated/test-runner-data/v2/action-connection/create-a-new-action-connection-returns-bad-request-response.json b/features/generated/test-runner-data/v2/action-connection/create-a-new-action-connection-returns-bad-request-response.json new file mode 100644 index 000000000000..9d62192fa86c --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/create-a-new-action-connection-returns-bad-request-response.json @@ -0,0 +1,41 @@ +{ + "api": "ActionConnection", + "expected_status": 400, + "feature": "Action Connection", + "id": "v2/Action Connection/Create a new Action Connection returns \"Bad Request\" response", + "operation_id": "CreateActionConnection", + "request": { + "body": { + "schema": { + "format": null, + "ref": "CreateActionConnectionRequest", + "type": "object" + }, + "source": "inline", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "1", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "POST", + "pagination": false, + "parameters": [], + "path": "/api/v2/actions/connections" + }, + "scenario": "Create a new Action Connection returns \"Bad Request\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/create-a-new-action-connection-returns-successfully-created-action-connection-response.json b/features/generated/test-runner-data/v2/action-connection/create-a-new-action-connection-returns-successfully-created-action-connection-response.json new file mode 100644 index 000000000000..74b0385b6fba --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/create-a-new-action-connection-returns-successfully-created-action-connection-response.json @@ -0,0 +1,41 @@ +{ + "api": "ActionConnection", + "expected_status": 201, + "feature": "Action Connection", + "id": "v2/Action Connection/Create a new Action Connection returns \"Successfully created Action Connection\" response", + "operation_id": "CreateActionConnection", + "request": { + "body": { + "schema": { + "format": null, + "ref": "CreateActionConnectionRequest", + "type": "object" + }, + "source": "inline", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection {{ unique_lower_alnum }}" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "POST", + "pagination": false, + "parameters": [], + "path": "/api/v2/actions/connections" + }, + "scenario": "Create a new Action Connection returns \"Successfully created Action Connection\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/delete-an-existing-action-connection-returns-not-found-response.json b/features/generated/test-runner-data/v2/action-connection/delete-an-existing-action-connection-returns-not-found-response.json new file mode 100644 index 000000000000..da2b07a07353 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/delete-an-existing-action-connection-returns-not-found-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 404, + "feature": "Action Connection", + "id": "v2/Action Connection/Delete an existing Action Connection returns \"Not Found\" response", + "operation_id": "DeleteActionConnection", + "request": { + "body": null, + "content_type": null, + "method": "DELETE", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "aaa11111-aa11-aa11-aaaa-aaaaaa111111" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Delete an existing Action Connection returns \"Not Found\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/delete-an-existing-action-connection-returns-the-resource-was-deleted-successfully-response.json b/features/generated/test-runner-data/v2/action-connection/delete-an-existing-action-connection-returns-the-resource-was-deleted-successfully-response.json new file mode 100644 index 000000000000..43463f4296d0 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/delete-an-existing-action-connection-returns-the-resource-was-deleted-successfully-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 204, + "feature": "Action Connection", + "id": "v2/Action Connection/Delete an existing Action Connection returns \"The resource was deleted successfully.\" response", + "operation_id": "DeleteActionConnection", + "request": { + "body": null, + "content_type": null, + "method": "DELETE", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "path": "action_connection.data.id", + "type": "fixture" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Delete an existing Action Connection returns \"The resource was deleted successfully.\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-bad-request-response.json b/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-bad-request-response.json new file mode 100644 index 000000000000..49fd555793d5 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-bad-request-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 400, + "feature": "Action Connection", + "id": "v2/Action Connection/Get an existing Action Connection returns \"Bad Request\" response", + "operation_id": "GetActionConnection", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "bad-format" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Get an existing Action Connection returns \"Bad Request\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-not-found-response.json b/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-not-found-response.json new file mode 100644 index 000000000000..03d0a84449ee --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-not-found-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 404, + "feature": "Action Connection", + "id": "v2/Action Connection/Get an existing Action Connection returns \"Not Found\" response", + "operation_id": "GetActionConnection", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "aaa11111-aa11-aa11-aaaa-aaaaaa111111" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Get an existing Action Connection returns \"Not Found\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-successfully-get-action-connection-response.json b/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-successfully-get-action-connection-response.json new file mode 100644 index 000000000000..a98a52d0465d --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/get-an-existing-action-connection-returns-successfully-get-action-connection-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 200, + "feature": "Action Connection", + "id": "v2/Action Connection/Get an existing Action Connection returns \"Successfully get Action Connection\" response", + "operation_id": "GetActionConnection", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "cb460d51-3c88-4e87-adac-d47131d0423d" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Get an existing Action Connection returns \"Successfully get Action Connection\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-bad-request-response.json b/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-bad-request-response.json new file mode 100644 index 000000000000..3ca91e008f8a --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-bad-request-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 400, + "feature": "Action Connection", + "id": "v2/Action Connection/Get an existing App Key Registration returns \"Bad request\" response", + "operation_id": "GetAppKeyRegistration", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "not_valid_app_key_id" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Get an existing App Key Registration returns \"Bad request\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-not-found-response.json b/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-not-found-response.json new file mode 100644 index 000000000000..5a4f3924a1a1 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-not-found-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 404, + "feature": "Action Connection", + "id": "v2/Action Connection/Get an existing App Key Registration returns \"Not found\" response", + "operation_id": "GetAppKeyRegistration", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "aaa11111-aa11-aa11-aaaa-aaaaaa111111" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Get an existing App Key Registration returns \"Not found\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-ok-response.json b/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-ok-response.json new file mode 100644 index 000000000000..31213bc9e3b6 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/get-an-existing-app-key-registration-returns-ok-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 200, + "feature": "Action Connection", + "id": "v2/Action Connection/Get an existing App Key Registration returns \"OK\" response", + "operation_id": "GetAppKeyRegistration", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "b7feea52-994e-4714-a100-1bd9eff5aee1" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Get an existing App Key Registration returns \"OK\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/list-app-key-registrations-returns-bad-request-response.json b/features/generated/test-runner-data/v2/action-connection/list-app-key-registrations-returns-bad-request-response.json new file mode 100644 index 000000000000..d49839a5aa3b --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/list-app-key-registrations-returns-bad-request-response.json @@ -0,0 +1,18 @@ +{ + "api": "ActionConnection", + "expected_status": 400, + "feature": "Action Connection", + "id": "v2/Action Connection/List App Key Registrations returns \"Bad request\" response", + "operation_id": "ListAppKeyRegistrations", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [], + "path": "/api/v2/actions/app_key_registrations" + }, + "scenario": "List App Key Registrations returns \"Bad request\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/list-app-key-registrations-returns-ok-response.json b/features/generated/test-runner-data/v2/action-connection/list-app-key-registrations-returns-ok-response.json new file mode 100644 index 000000000000..772a8798f02b --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/list-app-key-registrations-returns-ok-response.json @@ -0,0 +1,18 @@ +{ + "api": "ActionConnection", + "expected_status": 200, + "feature": "Action Connection", + "id": "v2/Action Connection/List App Key Registrations returns \"OK\" response", + "operation_id": "ListAppKeyRegistrations", + "request": { + "body": null, + "content_type": null, + "method": "GET", + "pagination": false, + "parameters": [], + "path": "/api/v2/actions/app_key_registrations" + }, + "scenario": "List App Key Registrations returns \"OK\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/register-a-new-app-key-returns-bad-request-response.json b/features/generated/test-runner-data/v2/action-connection/register-a-new-app-key-returns-bad-request-response.json new file mode 100644 index 000000000000..7a6eeceb79fb --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/register-a-new-app-key-returns-bad-request-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 400, + "feature": "Action Connection", + "id": "v2/Action Connection/Register a new App Key returns \"Bad request\" response", + "operation_id": "RegisterAppKey", + "request": { + "body": null, + "content_type": null, + "method": "PUT", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "not_valid_app_key_id" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Register a new App Key returns \"Bad request\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/register-a-new-app-key-returns-created-response.json b/features/generated/test-runner-data/v2/action-connection/register-a-new-app-key-returns-created-response.json new file mode 100644 index 000000000000..5b28bbb7d05e --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/register-a-new-app-key-returns-created-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 201, + "feature": "Action Connection", + "id": "v2/Action Connection/Register a new App Key returns \"Created\" response", + "operation_id": "RegisterAppKey", + "request": { + "body": null, + "content_type": null, + "method": "PUT", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "b7feea52-994e-4714-a100-1bd9eff5aee1" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Register a new App Key returns \"Created\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-bad-request-response.json b/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-bad-request-response.json new file mode 100644 index 000000000000..8da94627d1c3 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-bad-request-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 400, + "feature": "Action Connection", + "id": "v2/Action Connection/Unregister an App Key returns \"Bad request\" response", + "operation_id": "UnregisterAppKey", + "request": { + "body": null, + "content_type": null, + "method": "DELETE", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "not_valid_app_key_id" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Unregister an App Key returns \"Bad request\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-no-content-response.json b/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-no-content-response.json new file mode 100644 index 000000000000..cc575c22eb82 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-no-content-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 204, + "feature": "Action Connection", + "id": "v2/Action Connection/Unregister an App Key returns \"No Content\" response", + "operation_id": "UnregisterAppKey", + "request": { + "body": null, + "content_type": null, + "method": "DELETE", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "57cc69ae-9214-4ecc-8df8-43ecc1d92d99" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Unregister an App Key returns \"No Content\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-not-found-response.json b/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-not-found-response.json new file mode 100644 index 000000000000..c3addf2664f9 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/unregister-an-app-key-returns-not-found-response.json @@ -0,0 +1,32 @@ +{ + "api": "ActionConnection", + "expected_status": 404, + "feature": "Action Connection", + "id": "v2/Action Connection/Unregister an App Key returns \"Not found\" response", + "operation_id": "UnregisterAppKey", + "request": { + "body": null, + "content_type": null, + "method": "DELETE", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "app_key_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "57cc69ae-9214-4ecc-8df8-43ecc1d92d99" + } + } + ], + "path": "/api/v2/actions/app_key_registrations/{app_key_id}" + }, + "scenario": "Unregister an App Key returns \"Not found\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-bad-request-response.json b/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-bad-request-response.json new file mode 100644 index 000000000000..310f8c7f60a6 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-bad-request-response.json @@ -0,0 +1,55 @@ +{ + "api": "ActionConnection", + "expected_status": 400, + "feature": "Action Connection", + "id": "v2/Action Connection/Update an existing Action Connection returns \"Bad Request\" response", + "operation_id": "UpdateActionConnection", + "request": { + "body": { + "schema": { + "format": null, + "ref": "UpdateActionConnectionRequest", + "type": "object" + }, + "source": "inline", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "1", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "PATCH", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "cb460d51-3c88-4e87-adac-d47131d0423d" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Update an existing Action Connection returns \"Bad Request\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-not-found-response.json b/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-not-found-response.json new file mode 100644 index 000000000000..46599cba936f --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-not-found-response.json @@ -0,0 +1,55 @@ +{ + "api": "ActionConnection", + "expected_status": 404, + "feature": "Action Connection", + "id": "v2/Action Connection/Update an existing Action Connection returns \"Not Found\" response", + "operation_id": "UpdateActionConnection", + "request": { + "body": { + "schema": { + "format": null, + "ref": "UpdateActionConnectionRequest", + "type": "object" + }, + "source": "inline", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "PATCH", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "aaa11111-aa11-aa11-aaaa-aaaaaa111111" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Update an existing Action Connection returns \"Not Found\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-successfully-updated-action-connection-response.json b/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-successfully-updated-action-connection-response.json new file mode 100644 index 000000000000..1bd50f9b4084 --- /dev/null +++ b/features/generated/test-runner-data/v2/action-connection/update-an-existing-action-connection-returns-successfully-updated-action-connection-response.json @@ -0,0 +1,55 @@ +{ + "api": "ActionConnection", + "expected_status": 200, + "feature": "Action Connection", + "id": "v2/Action Connection/Update an existing Action Connection returns \"Successfully updated Action Connection\" response", + "operation_id": "UpdateActionConnection", + "request": { + "body": { + "schema": { + "format": null, + "ref": "UpdateActionConnectionRequest", + "type": "object" + }, + "source": "inline", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "PATCH", + "pagination": false, + "parameters": [ + { + "in": "path", + "name": "connection_id", + "schema": { + "format": null, + "ref": null, + "type": "string" + }, + "source": { + "type": "literal", + "value": "cb460d51-3c88-4e87-adac-d47131d0423d" + } + } + ], + "path": "/api/v2/actions/connections/{connection_id}" + }, + "scenario": "Update an existing Action Connection returns \"Successfully updated Action Connection\" response", + "schema_version": 1, + "version": "v2" +} diff --git a/features/generated/test-server b/features/generated/test-server new file mode 100755 index 000000000000..a6b99b0b3ef8 --- /dev/null +++ b/features/generated/test-server @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +# ruff: noqa: A002, BLE001, EM101, EM102, S310, T201, TRY003 +"""Standalone OpenAPI BDD recording replay and capture server. + +This file is copied verbatim by ``openapi-transformer generate test-server``. +It deliberately uses only the Python standard library. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import tempfile +import threading +import uuid +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.parse import parse_qsl, urlsplit +from urllib.request import Request, urlopen + +CONTROL_ROOT = "/__openapi_transformer__" +SESSION_HEADER = "x-openapi-test-session" +SCHEMA_VERSION = 1 +HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +} + + +class RecordingDatabase: + def __init__(self, root: Path) -> None: + self.root = root + self.lock = threading.RLock() + self.shards: dict[tuple[str, str], dict[str, Any]] = {} + self.shard_paths: dict[tuple[str, str], Path] = {} + self.sessions: dict[str, dict[str, Any]] = {} + self.fallback_consumed: set[tuple[str, str, str, int]] = set() + self._load() + + def _load(self) -> None: + manifest_path = self.root / "manifest.json" + if not manifest_path.exists(): + raise RuntimeError(f"Database manifest not found: {manifest_path}") + manifest = _read_json(manifest_path) + if manifest.get("schema_version") != SCHEMA_VERSION: + raise RuntimeError(f"Unsupported database schema: {manifest.get('schema_version')}") + for item in manifest.get("features", []): + path = self.root / item["file"] + shard = _read_json(path) + key = (shard["version"], shard["feature"]) + self.shards[key] = shard + self.shard_paths[key] = path + + def start(self, version: str, feature: str, scenario: str, mode: str) -> dict[str, Any]: + with self.lock: + key = (version, feature) + shard = self.shards.get(key) + recording = None + if shard is not None: + recording = next( + (item for item in shard["recordings"] if item["scenario"] == scenario), + None, + ) + if mode == "replay" and recording is None: + raise LookupError(f"Recording not found: {version}/{feature}/{scenario}") + + frozen_at = recording["frozen_at"] if recording is not None else _now_iso() + session_id = uuid.uuid4().hex + self.sessions[session_id] = { + "id": session_id, + "mode": mode, + "key": key, + "scenario": scenario, + "recording": recording, + "cursor": 0, + "captures": [], + "frozen_at": frozen_at, + } + return {"session": session_id, "frozen_at": frozen_at} + + def replay(self, session_id: str | None, actual: dict[str, Any]) -> dict[str, Any]: + with self.lock: + if session_id: + session = self.sessions.get(session_id) + if session is None: + raise LookupError(f"Unknown session: {session_id}") + recording = session["recording"] + cursor = session["cursor"] + interactions = recording["interactions"] + if cursor >= len(interactions): + raise LookupError(f"Recording has no interaction #{cursor + 1}") + expected = interactions[cursor] + if expected["request"] != actual: + raise RequestMismatchError(expected["request"], actual, cursor) + session["cursor"] += 1 + return expected["response"] + + for (version, feature), shard in sorted(self.shards.items()): + for recording in shard["recordings"]: + for index, interaction in enumerate(recording["interactions"]): + consumed_key = (version, feature, recording["scenario"], index) + if consumed_key not in self.fallback_consumed and interaction["request"] == actual: + self.fallback_consumed.add(consumed_key) + return interaction["response"] + raise LookupError("No unconsumed interaction matches this request") + + def capture( + self, + session_id: str | None, + request: dict[str, Any], + response: dict[str, Any], + ) -> None: + if not session_id: + raise LookupError("Capture requests require the x-openapi-test-session header") + with self.lock: + session = self.sessions.get(session_id) + if session is None: + raise LookupError(f"Unknown session: {session_id}") + session["captures"].append({"request": request, "response": response}) + + def stop(self, session_id: str) -> dict[str, Any]: + with self.lock: + session = self.sessions.pop(session_id, None) + if session is None: + raise LookupError(f"Unknown session: {session_id}") + if session["mode"] == "replay": + expected = len(session["recording"]["interactions"]) + consumed = session["cursor"] + return { + "interactions": consumed, + "total_interactions": expected, + "complete": consumed == expected, + } + + key = session["key"] + shard = self.shards.get(key) + if shard is None: + shard = { + "schema_version": SCHEMA_VERSION, + "version": key[0], + "feature": key[1], + "recordings": [], + } + self.shards[key] = shard + version_dir = self.root / key[0] + version_dir.mkdir(parents=True, exist_ok=True) + self.shard_paths[key] = version_dir / f"{_slug(key[1])}.json" + self._add_manifest_entry(key, self.shard_paths[key]) + + recording = { + "feature": key[1], + "scenario": session["scenario"], + "version": key[0], + "frozen_at": session["frozen_at"], + "interactions": session["captures"], + } + shard["recordings"] = [item for item in shard["recordings"] if item["scenario"] != session["scenario"]] + shard["recordings"].append(recording) + shard["recordings"].sort(key=lambda item: item["scenario"]) + _write_json_atomic(self.shard_paths[key], shard) + return {"interactions": len(session["captures"]), "file": str(self.shard_paths[key])} + + def _add_manifest_entry(self, key: tuple[str, str], shard_path: Path) -> None: + manifest_path = self.root / "manifest.json" + manifest = _read_json(manifest_path) + relative = str(shard_path.relative_to(self.root)) + manifest["features"] = [item for item in manifest["features"] if (item["version"], item["feature"]) != key] + manifest["features"].append({"version": key[0], "feature": key[1], "file": relative}) + manifest["features"].sort(key=lambda item: (item["version"], item["feature"])) + _write_json_atomic(manifest_path, manifest) + + +class RequestMismatchError(Exception): + def __init__(self, expected: dict[str, Any], actual: dict[str, Any], index: int) -> None: + super().__init__(f"Request does not match interaction #{index + 1}") + self.expected = expected + self.actual = actual + self.index = index + + +class TestServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, address: tuple[str, int], database: RecordingDatabase, mode: str, upstream: str | None): + super().__init__(address, TestRequestHandler) + self.database = database + self.mode = mode + self.upstream = upstream.rstrip("/") if upstream else None + + +class TestRequestHandler(BaseHTTPRequestHandler): + server: TestServer + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: + self._handle() + + def do_POST(self) -> None: + self._handle() + + def do_PUT(self) -> None: + self._handle() + + def do_PATCH(self) -> None: + self._handle() + + def do_DELETE(self) -> None: + self._handle() + + def do_HEAD(self) -> None: + self._handle() + + def do_OPTIONS(self) -> None: + self._handle() + + def log_message(self, format: str, *args: Any) -> None: + print(f"{self.address_string()} - {format % args}", flush=True) + + def _handle(self) -> None: + try: + if self.path == f"{CONTROL_ROOT}/health": + self._send_json(HTTPStatus.OK, {"status": "ok", "mode": self.server.mode}) + return + if self.path == f"{CONTROL_ROOT}/sessions" and self.command == "POST": + self._start_session() + return + stop_match = re.fullmatch(rf"{re.escape(CONTROL_ROOT)}/sessions/([a-f0-9]+)/stop", self.path) + if stop_match and self.command == "POST": + result = self.server.database.stop(stop_match.group(1)) + self._send_json(HTTPStatus.OK, result) + return + self._handle_api_request() + except RequestMismatchError as error: + self._send_json( + HTTPStatus.CONFLICT, + { + "error": str(error), + "interaction": error.index + 1, + "expected": error.expected, + "actual": error.actual, + }, + error="request-mismatch", + ) + except (LookupError, ValueError) as error: + self._send_json(HTTPStatus.NOT_FOUND, {"error": str(error)}, error="recording-not-found") + except Exception as error: + self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(error)}, error="internal-error") + + def _start_session(self) -> None: + payload = json.loads(self._read_body().decode("utf-8")) + result = self.server.database.start( + version=payload["version"], + feature=payload["feature"], + scenario=payload["scenario"], + mode=self.server.mode, + ) + self._send_json(HTTPStatus.CREATED, result) + + def _handle_api_request(self) -> None: + body = self._read_body() + actual = _normalise_request(self.command, self.path, self.headers.get("content-type", ""), body) + session_id = self.headers.get(SESSION_HEADER) + if self.server.mode == "replay": + response = self.server.database.replay(session_id, actual) + else: + response = self._forward(body) + self.server.database.capture(session_id, actual, response) + self._send_recorded_response(response) + + def _forward(self, body: bytes) -> dict[str, Any]: + if not self.server.upstream: + raise ValueError("Capture mode requires --upstream") + target = self.server.upstream + self.path + headers = { + key: value + for key, value in self.headers.items() + if key.lower() not in HOP_BY_HOP_HEADERS | {"host", "content-length", SESSION_HEADER} + } + request = Request(target, data=body or None, headers=headers, method=self.command) + try: + remote = urlopen(request) + except HTTPError as error: + remote = error + try: + response_body = remote.read() + response_headers = { + key.lower(): value + for key, value in remote.headers.items() + if key.lower() not in HOP_BY_HOP_HEADERS | {"content-length"} + } + return { + "status": remote.status, + "reason": remote.reason, + "headers": response_headers, + "body": {"encoding": "base64", "value": base64.b64encode(response_body).decode("ascii")}, + } + finally: + remote.close() + + def _read_body(self) -> bytes: + length = int(self.headers.get("content-length", "0")) + return self.rfile.read(length) if length else b"" + + def _send_recorded_response(self, response: dict[str, Any]) -> None: + body_data = response.get("body", {}) + if body_data.get("encoding") == "base64": + body = base64.b64decode(body_data.get("value", "")) + else: + body = body_data.get("value", "").encode("utf-8") + self.send_response(response["status"], response.get("reason")) + for key, value in response.get("headers", {}).items(): + if key.lower() not in HOP_BY_HOP_HEADERS | {"content-length"}: + self.send_header(key, value) + self.send_header("content-length", str(len(body))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def _send_json(self, status: HTTPStatus, value: dict[str, Any], *, error: str | None = None) -> None: + body = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + if error: + self.send_header("x-openapi-test-error", error) + self.send_header("content-length", str(len(body))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + +def _normalise_request(method: str, raw_path: str, content_type: str, body: bytes) -> dict[str, Any]: + parsed = urlsplit(raw_path) + return { + "method": method.upper(), + "path": parsed.path or "/", + "query": sorted([list(pair) for pair in parse_qsl(parsed.query, keep_blank_values=True)]), + "content_type": _media_type(content_type), + "body": _normalise_body(body, content_type), + } + + +def _normalise_body(body: bytes, content_type: str) -> dict[str, Any]: + if not body: + return {"type": "empty", "value": None} + media_type = _media_type(content_type) + text = body.decode("utf-8", errors="surrogateescape") + if media_type.endswith("json"): + try: + return {"type": "json", "value": json.loads(text)} + except json.JSONDecodeError: + pass + if media_type == "multipart/form-data": + boundary_match = re.search(r"boundary=([^;]+)", content_type, re.IGNORECASE) + if boundary_match: + text = text.replace(boundary_match.group(1).strip('"'), "x" * 70) + return {"type": "text", "value": text} + + +def _media_type(value: str) -> str: + return value.partition(";")[0].strip().lower() + + +def _slug(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "feature" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json_atomic(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as file: + json.dump(value, file, indent=2, sort_keys=True) + file.write("\n") + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--database", + type=Path, + default=Path(__file__).resolve().with_name("test-server-data"), + help="Generated recording database directory (default: beside this executable).", + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--mode", choices=("replay", "capture"), default="replay") + parser.add_argument("--upstream", help="Real API base URL; required in capture mode.") + args = parser.parse_args() + if args.mode == "capture" and not args.upstream: + parser.error("--upstream is required in capture mode") + + database = RecordingDatabase(args.database) + server = TestServer((args.host, args.port), database, args.mode, args.upstream) + host = str(server.server_address[0]) + port = int(server.server_address[1]) + print(f"Listening on http://{host}:{port} ({args.mode})", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/features/generated/test-server-data/manifest.json b/features/generated/test-server-data/manifest.json new file mode 100644 index 000000000000..530bdff4bcd5 --- /dev/null +++ b/features/generated/test-server-data/manifest.json @@ -0,0 +1,10 @@ +{ + "features": [ + { + "feature": "Action Connection", + "file": "v2/action-connection.json", + "version": "v2" + } + ], + "schema_version": 1 +} diff --git a/features/generated/test-server-data/v2/action-connection.json b/features/generated/test-server-data/v2/action-connection.json new file mode 100644 index 000000000000..6eeb7706ab54 --- /dev/null +++ b/features/generated/test-server-data/v2/action-connection.json @@ -0,0 +1,810 @@ +{ + "feature": "Action Connection", + "recordings": [ + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:24:59.622Z", + "interactions": [ + { + "request": { + "body": { + "type": "json", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "1", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "POST", + "path": "/api/v2/actions/connections", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error creating connection: rpc error: code = InvalidArgument desc = multiple errors: 1 error occurred:\\n\\t* [error_code=8]: invalid CreateCustomConnectionRequest.Data: embedded message failed validation | caused by: invalid CustomConnectionData.Aws: embedded message failed validation | caused by: invalid CustomConnectionData_AwsAuthData.AssumeRole: embedded message failed validation | caused by: invalid CustomConnectionData_AwsAuthData_AssumeRole.AccountId: value does not match regex pattern \\\"^\\\\\\\\d{12}$\\\"\\n\\n\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Bad Request", + "status": 400 + } + } + ], + "scenario": "Create a new Action Connection returns \"Bad Request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:24:59.727Z", + "interactions": [ + { + "request": { + "body": { + "type": "json", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection testcreateanewactionconnectionreturnssuccessfullycreatedactionconnectionresponse1743020699" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "POST", + "path": "/api/v2/actions/connections", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":{\"id\":\"67aa2f61-266d-48d1-a1ee-cf7c3acc6b20\",\"type\":\"action_connection\",\"attributes\":{\"integration\":{\"credentials\":{\"account_id\":\"123456789123\",\"external_id\":\"70f4660c99684420821b0c13a67eb4d1\",\"principal_id\":\"464622532012\",\"role\":\"MyRoleUpdated\",\"type\":\"AWSAssumeRole\"},\"type\":\"AWS\"},\"name\":\"Cassette Connection testcreateanewactionconnectionreturnssuccessfullycreatedactionconnectionresponse1743020699\"}}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Created", + "status": 201 + } + }, + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/connections/67aa2f61-266d-48d1-a1ee-cf7c3acc6b20", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "" + }, + "headers": {}, + "reason": "No Content", + "status": 204 + } + } + ], + "scenario": "Create a new Action Connection returns \"Successfully created Action Connection\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:00.311Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/connections/aaa11111-aa11-aa11-aaaa-aaaaaa111111", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error deleting connection: rpc error: code = NotFound desc = connection not found\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Not Found", + "status": 404 + } + } + ], + "scenario": "Delete an existing Action Connection returns \"Not Found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-01-06T22:02:36.636Z", + "interactions": [ + { + "request": { + "body": { + "type": "json", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRole", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection DELETE" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "POST", + "path": "/api/v2/actions/connections", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":{\"id\":\"4b60345a-85b2-4417-94b2-72a9528b4060\",\"type\":\"action_connection\",\"attributes\":{\"integration\":{\"credentials\":{\"account_id\":\"123456789123\",\"role\":\"MyRole\",\"external_id\":\"3bceadebe70c4df7b8ec6abb789e08c0\",\"principal_id\":\"464622532012\",\"type\":\"AWSAssumeRole\"},\"type\":\"AWS\"},\"name\":\"Cassette Connection DELETE\"}}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Created", + "status": 201 + } + }, + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/connections/4b60345a-85b2-4417-94b2-72a9528b4060", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "" + }, + "headers": {}, + "reason": "No Content", + "status": 204 + } + }, + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/connections/4b60345a-85b2-4417-94b2-72a9528b4060", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error deleting connection: rpc error: code = NotFound desc = connection not found\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Not Found", + "status": 404 + } + } + ], + "scenario": "Delete an existing Action Connection returns \"Successfully deleted Action Connection\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:00.458Z", + "interactions": [ + { + "request": { + "body": { + "type": "json", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRole", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection testdeleteanexistingactionconnectionreturnstheresourcewasdeletedsuccessfullyresponse1743020700" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "POST", + "path": "/api/v2/actions/connections", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":{\"id\":\"c3c3d83b-c495-4c1b-bb1d-592825d21db0\",\"type\":\"action_connection\",\"attributes\":{\"integration\":{\"credentials\":{\"account_id\":\"123456789123\",\"external_id\":\"6183670d1ceb4278808cf2df9aa03a07\",\"principal_id\":\"464622532012\",\"role\":\"MyRole\",\"type\":\"AWSAssumeRole\"},\"type\":\"AWS\"},\"name\":\"Cassette Connection testdeleteanexistingactionconnectionreturnstheresourcewasdeletedsuccessfullyresponse1743020700\"}}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Created", + "status": 201 + } + }, + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/connections/c3c3d83b-c495-4c1b-bb1d-592825d21db0", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "" + }, + "headers": {}, + "reason": "No Content", + "status": 204 + } + }, + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/connections/c3c3d83b-c495-4c1b-bb1d-592825d21db0", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error deleting connection: rpc error: code = NotFound desc = connection not found\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Not Found", + "status": 404 + } + } + ], + "scenario": "Delete an existing Action Connection returns \"The resource was deleted successfully.\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:01.150Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "GET", + "path": "/api/v2/actions/connections/bad-format", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error connectionId not a valid UUID\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Bad Request", + "status": 400 + } + } + ], + "scenario": "Get an existing Action Connection returns \"Bad Request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:01.247Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "GET", + "path": "/api/v2/actions/connections/aaa11111-aa11-aa11-aaaa-aaaaaa111111", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error getting connection: rpc error: code = NotFound desc = connection not found\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Not Found", + "status": 404 + } + } + ], + "scenario": "Get an existing Action Connection returns \"Not Found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:01.398Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "GET", + "path": "/api/v2/actions/connections/cb460d51-3c88-4e87-adac-d47131d0423d", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":{\"id\":\"cb460d51-3c88-4e87-adac-d47131d0423d\",\"type\":\"action_connection\",\"attributes\":{\"integration\":{\"credentials\":{\"account_id\":\"123456789123\",\"external_id\":\"909b33b1242748cfbef42f20011e2fa0\",\"principal_id\":\"464622532012\",\"role\":\"MyRoleUpdated\",\"type\":\"AWSAssumeRole\"},\"type\":\"AWS\"},\"name\":\"Cassette Connection\"}}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "OK", + "status": 200 + } + } + ], + "scenario": "Get an existing Action Connection returns \"Successfully get Action Connection\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T17:54:59.658Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "GET", + "path": "/api/v2/actions/app_key_registrations/not_valid_app_key_id", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"status\":\"400\",\"title\":\"Invalid Parameter\",\"detail\":\"invalid parameter \\\"appKeyId\\\" in \\\"path\\\"; expected type \\\"uuid\\\"\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Bad Request", + "status": 400 + } + } + ], + "scenario": "Get an existing App Key Registration returns \"Bad request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T18:04:07.176Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "GET", + "path": "/api/v2/actions/app_key_registrations/aaa11111-aa11-aa11-aaaa-aaaaaa111111", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"detail\":\"app key not found\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Not Found", + "status": 404 + } + } + ], + "scenario": "Get an existing App Key Registration returns \"Not found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T18:04:07.258Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "GET", + "path": "/api/v2/actions/app_key_registrations/b7feea52-994e-4714-a100-1bd9eff5aee1", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":{\"id\":\"b7feea52-994e-4714-a100-1bd9eff5aee1\",\"type\":\"app_key_registration\"}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "OK", + "status": 200 + } + } + ], + "scenario": "Get an existing App Key Registration returns \"OK\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T18:04:07.439Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "GET", + "path": "/api/v2/actions/app_key_registrations", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":[{\"id\":\"b7feea52-994e-4714-a100-1bd9eff5aee1\",\"type\":\"app_key_registration\"}],\"meta\":{\"total\":1,\"total_filtered\":1}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "OK", + "status": 200 + } + } + ], + "scenario": "List App Key Registrations returns \"OK\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T17:55:00.012Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "PUT", + "path": "/api/v2/actions/app_key_registrations/not_valid_app_key_id", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"status\":\"400\",\"title\":\"Invalid Parameter\",\"detail\":\"invalid parameter \\\"appKeyId\\\" in \\\"path\\\"; expected type \\\"uuid\\\"\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Bad Request", + "status": 400 + } + } + ], + "scenario": "Register a new App Key returns \"Bad request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T18:04:07.513Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "PUT", + "path": "/api/v2/actions/app_key_registrations/b7feea52-994e-4714-a100-1bd9eff5aee1", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":{\"id\":\"b7feea52-994e-4714-a100-1bd9eff5aee1\",\"type\":\"app_key_registration\"}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Created", + "status": 201 + } + } + ], + "scenario": "Register a new App Key returns \"Created\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T17:55:00.202Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/app_key_registrations/not_valid_app_key_id", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"status\":\"400\",\"title\":\"Invalid Parameter\",\"detail\":\"invalid parameter \\\"appKeyId\\\" in \\\"path\\\"; expected type \\\"uuid\\\"\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Bad Request", + "status": 400 + } + } + ], + "scenario": "Unregister an App Key returns \"Bad request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-06-13T18:04:07.614Z", + "interactions": [ + { + "request": { + "body": { + "type": "empty", + "value": null + }, + "content_type": "", + "method": "DELETE", + "path": "/api/v2/actions/app_key_registrations/57cc69ae-9214-4ecc-8df8-43ecc1d92d99", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"detail\":\"app key not found\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Not Found", + "status": 404 + } + } + ], + "scenario": "Unregister an App Key returns \"Not found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:01.521Z", + "interactions": [ + { + "request": { + "body": { + "type": "json", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "1", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "PATCH", + "path": "/api/v2/actions/connections/cb460d51-3c88-4e87-adac-d47131d0423d", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error creating connection: rpc error: code = InvalidArgument desc = multiple errors: 1 error occurred:\\n\\t* [error_code=8]: invalid UpdateCustomConnectionRequest.DataUpdate: embedded message failed validation | caused by: invalid CustomConnectionDataUpdate.Aws: embedded message failed validation | caused by: invalid CustomConnectionDataUpdate_AwsAuth.AssumeRole: embedded message failed validation | caused by: invalid CustomConnectionDataUpdate_AwsAuth_AssumeRole.AccountId: value does not match regex pattern \\\"^\\\\\\\\d{12}$\\\"\\n\\n\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Bad Request", + "status": 400 + } + } + ], + "scenario": "Update an existing Action Connection returns \"Bad Request\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:01.625Z", + "interactions": [ + { + "request": { + "body": { + "type": "json", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "PATCH", + "path": "/api/v2/actions/connections/aaa11111-aa11-aa11-aaaa-aaaaaa111111", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"errors\":[{\"title\":\"Generic Error\",\"detail\":\"error creating connection: rpc error: code = NotFound desc = connection not found\"}]}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "Not Found", + "status": 404 + } + } + ], + "scenario": "Update an existing Action Connection returns \"Not Found\" response", + "version": "v2" + }, + { + "feature": "Action Connection", + "frozen_at": "2025-03-26T20:25:01.719Z", + "interactions": [ + { + "request": { + "body": { + "type": "json", + "value": { + "data": { + "attributes": { + "integration": { + "credentials": { + "account_id": "123456789123", + "role": "MyRoleUpdated", + "type": "AWSAssumeRole" + }, + "type": "AWS" + }, + "name": "Cassette Connection" + }, + "type": "action_connection" + } + } + }, + "content_type": "application/json", + "method": "PATCH", + "path": "/api/v2/actions/connections/cb460d51-3c88-4e87-adac-d47131d0423d", + "query": [] + }, + "response": { + "body": { + "encoding": "text", + "value": "{\"data\":{\"id\":\"cb460d51-3c88-4e87-adac-d47131d0423d\",\"type\":\"action_connection\",\"attributes\":{\"integration\":{\"credentials\":{\"account_id\":\"123456789123\",\"external_id\":\"909b33b1242748cfbef42f20011e2fa0\",\"principal_id\":\"464622532012\",\"role\":\"MyRoleUpdated\",\"type\":\"AWSAssumeRole\"},\"type\":\"AWS\"},\"name\":\"Cassette Connection\"}}}" + }, + "headers": { + "content-type": "application/vnd.api+json" + }, + "reason": "OK", + "status": 200 + } + } + ], + "scenario": "Update an existing Action Connection returns \"Successfully updated Action Connection\" response", + "version": "v2" + } + ], + "schema_version": 1, + "version": "v2" +} diff --git a/features/step_definitions/request.rb b/features/step_definitions/request.rb index 8682557b083d..242fee920ea5 100644 --- a/features/step_definitions/request.rb +++ b/features/step_definitions/request.rb @@ -19,7 +19,14 @@ def configuration def from_env(configuration) configuration.configure do |c| - if ENV.key? 'DD_TEST_SITE' then + if ENV.key? 'DD_TEST_SERVER_URL' then + uri = URI(ENV['DD_TEST_SERVER_URL']) + name = uri.host + name += ":#{uri.port}" unless [80, 443].include?(uri.port) + c.server_index = 1 + c.server_variables[:name] = name + c.server_variables[:protocol] = uri.scheme + elsif ENV.key? 'DD_TEST_SITE' then c.server_index = 2 c.server_variables[:site] = ENV['DD_TEST_SITE'] end @@ -34,7 +41,7 @@ def from_env(configuration) end def api_client - @api_client ||= api::APIClient.new configuration + @api_client ||= add_test_server_session(api::APIClient.new(configuration)) end def api_error @@ -48,7 +55,7 @@ def sleep_after_request def unique now = Time.now.utc scenario_name = @scenario.name.gsub(/[^A-Za-z0-9]+/, '_')[0, 100] - prefix = ENV["RECORD"] == "none" ? "Test-Ruby" : "Test" + prefix = ENV["RECORD"] == "none" && !test_server_enabled? ? "Test-Ruby" : "Test" @unique ||= "#{prefix}-#{scenario_name}-#{now.to_i}" end @@ -153,6 +160,7 @@ def build_undo_for(version, operation_id, api_instance = nil) undo_configuration.api_key = ENV["DD_TEST_CLIENT_API_KEY"] undo_configuration.application_key = ENV["DD_TEST_CLIENT_APP_KEY"] undo_api_client = undo_api::APIClient.new undo_configuration + add_test_server_session(undo_api_client) api_instance = undo_api.const_get("V#{version}").const_get(api_name).new undo_api_client end @@ -216,6 +224,7 @@ def build_given(api_version, operation) given_configuration.api_key = ENV["DD_TEST_CLIENT_API_KEY"] given_configuration.application_key = ENV["DD_TEST_CLIENT_APP_KEY"] given_api_client = given_api::APIClient.new given_configuration + add_test_server_session(given_api_client) given_api_instance = given_api.const_get("V#{api_version}").const_get(api_name).new given_api_client method = given_api_instance.method("#{operation_name}_with_http_info".to_sym) @@ -274,8 +283,12 @@ def build_given(api_version, operation) result end - def model_builder(param, obj) - model = ScenariosModelMappings["v#{@api_version}.#{@operation_id}"][param] + def model_builder(param, obj, schema = nil) + model = if schema + test_runner_model(schema) + else + ScenariosModelMappings["v#{@api_version}.#{@operation_id}"][param] + end if model == 'File' return File.open(File.join(__dir__, "..", "v" + @api_version, obj)) end @@ -305,17 +318,23 @@ def model_builder(param, obj) end Given(/^body with value (.*)$/) do |body| + next if test_runner_enabled? + body_hash = JSON.parse(body.templated(fixtures), {:symbolize_names => true}) opts[:body] = model_builder("body", body_hash) end Given(/^body from file "(.*)"$/) do |file| + next if test_runner_enabled? + body = File.read(File.join(__dir__, "..", "v" + @api_version, file)) body_hash = JSON.parse(body.templated(fixtures), {:symbolize_names => true}) opts[:body] = model_builder("body", body_hash) end Given(/^request contains "([^"]+)" parameter from "([^"]+)"$/) do |parameter_name, fixture_path| + next if test_runner_enabled? + param_value = model_builder(parameter_name.to_parameter, fixtures.lookup(fixture_path)) param_key = parameter_name.to_parameter.to_sym opts[param_key] = param_value @@ -326,6 +345,8 @@ def model_builder(param, obj) end Given(/^request contains "([^"]+)" parameter with value (.+)$/) do |parameter_name, value| + next if test_runner_enabled? + param_value = model_builder(parameter_name.to_parameter, JSON.parse(value.templated fixtures)) param_key = parameter_name.to_parameter.to_sym opts[param_key] = param_value @@ -336,17 +357,22 @@ def model_builder(param, obj) end Given(/^new "([^"]+)" request$/) do |name| + next if test_runner_enabled? + @operation_id = name @api_method = @api_instance.method("#{name.snakecase}_with_http_info".to_sym) end When('the request is sent') do + prepare_test_runner_request if test_runner_enabled? params = @api_method.parameters.select { |p| p[0] == :req }.map { |p| opts.delete(p[1]) } undo_builder = build_undo_for(@api_version, @api_method.name.to_s.chomp('_with_http_info')) # fail early on missing undo method begin @response = @api_method.call(*params, opts) rescue api_error => e + raise e if e.response_headers && e.response_headers['x-openapi-test-error'] + # If we have an exception, make a stub response object to use for assertions # Instead of finding the response class of the method, we use the fact that all # responses returned have the `1` element set to the response code @@ -364,6 +390,7 @@ def model_builder(param, obj) end When('the request with pagination is sent') do + prepare_test_runner_request if test_runner_enabled? method_name = @api_method.name.to_s.chomp('_with_http_info') + "_with_pagination" method = @api_instance.method(method_name.to_sym) params = method.parameters.select { |p| p[0] == :req }.map { |p| opts.delete(p[1]) } @@ -373,6 +400,8 @@ def model_builder(param, obj) method.call(*params, opts) { |item| result.append(item) } @response = [result, 200, nil] rescue api_error => e + raise e if e.response_headers && e.response_headers['x-openapi-test-error'] + # If we have an exception, make a stub response object to use for assertions # Instead of finding the response class of the method, we use the fact that all # responses returned have the `1` element set to the response code diff --git a/features/support/hooks.rb b/features/support/hooks.rb index 177145da32b9..68e238e1b744 100644 --- a/features/support/hooks.rb +++ b/features/support/hooks.rb @@ -42,22 +42,33 @@ end Around do |scenario, block| - VCR.use_cassette(scenario.location.file.chomp('.feature') + "/" + scenario.name.gsub(/[^A-Za-z0-9]+/, '-')) do |cassette| - if !File.exist?(cassette.file) && ENV.fetch("RECORD", "false") == "false" && !scenario.match_tags?("@integration-only") - raise Exception.new "Cassette '#{cassette.file}' not found: create one setting `RECORD=true` or ignore it using `RECORD=none`" + if test_server_enabled? + freeze = start_test_server_session + begin + Timecop.freeze(freeze) do + block.call + end + ensure + stop_test_server_session end - File.delete(cassette.file) if ENV["RECORD"] == "true" && File.exist?(cassette.file) && !scenario.match_tags?("@replay-only") + else + VCR.use_cassette(scenario.location.file.chomp('.feature') + "/" + scenario.name.gsub(/[^A-Za-z0-9]+/, '-')) do |cassette| + if !File.exist?(cassette.file) && ENV.fetch("RECORD", "false") == "false" && !scenario.match_tags?("@integration-only") + raise Exception.new "Cassette '#{cassette.file}' not found: create one setting `RECORD=true` or ignore it using `RECORD=none`" + end + File.delete(cassette.file) if ENV["RECORD"] == "true" && File.exist?(cassette.file) && !scenario.match_tags?("@replay-only") - if use_real_time? then - freeze = Time.now.utc - else - File.open(cassette.file.gsub(/\.yml$/, '.frozen'), 'r') do |f| - freeze = Time.parse(f.readline.chomp) + if use_real_time? then + freeze = Time.now.utc + else + File.open(cassette.file.gsub(/\.yml$/, '.frozen'), 'r') do |f| + freeze = Time.parse(f.readline.chomp) + end end - end - Timecop.freeze(freeze) do - block.call + Timecop.freeze(freeze) do + block.call + end end end end diff --git a/features/support/test_runner.rb b/features/support/test_runner.rb new file mode 100644 index 000000000000..18aefcc8382a --- /dev/null +++ b/features/support/test_runner.rb @@ -0,0 +1,163 @@ +require 'json' +require 'net/http' +require 'uri' + +require_relative 'templating' + +module TestRunnerWorld + def test_runner_enabled? + !ENV['DD_TEST_RUNNER_DATA'].nil? + end + + def test_server_enabled? + !ENV['DD_TEST_SERVER_URL'].nil? + end + + def test_runner_plan + return @test_runner_plan if @test_runner_plan + + root = File.expand_path(ENV.fetch('DD_TEST_RUNNER_DATA')) + manifest = JSON.parse(File.read(File.join(root, 'manifest.json'))) + item = manifest.fetch('scenarios').find do |candidate| + candidate['version'] == "v#{@api_version}" && + candidate['feature'] == test_feature_name && + candidate['scenario'] == @scenario.name + end + raise "Generated request plan not found for v#{@api_version}/#{test_feature_name}/#{@scenario.name}" unless item + + @test_runner_plan = JSON.parse(File.read(File.join(root, item.fetch('file')))) + end + + def prepare_test_runner_request + plan = test_runner_plan + @operation_id = plan.fetch('operation_id') + @api_method = @api_instance.method("#{@operation_id.snakecase}_with_http_info".to_sym) + request = plan.fetch('request') + + if request['body'] + value = materialize_test_value(request['body'].fetch('value')) + value = JSON.parse(JSON.generate(value), :symbolize_names => true) + opts[:body] = model_builder('body', value, request['body']['schema']) + end + + request.fetch('parameters').each do |parameter| + source = parameter.fetch('source') + value = if source.fetch('type') == 'fixture' + fixtures.lookup(source.fetch('path')) + else + materialize_test_value(source.fetch('value')) + end + name = parameter.fetch('name') + opts[name.to_parameter.to_sym] = model_builder(name.to_parameter, value, parameter['schema']) + end + end + + def materialize_test_value(value) + if value.is_a?(Hash) && value.keys == ['$openapi_transformer_template'] + return JSON.parse(value.fetch('$openapi_transformer_template').templated(fixtures)) + end + if value.is_a?(Hash) + return value.transform_values { |item| materialize_test_value(item) } + end + if value.is_a?(Array) + return value.map { |item| materialize_test_value(item) } + end + return value.templated(fixtures) if value.is_a?(String) + + value + end + + def test_runner_model(schema) + if schema && schema['type'] == 'array' + return "Array<#{test_runner_model(schema.fetch('items'))}>" + end + return schema['ref'] if schema && schema['ref'] + + case schema && schema['type'] + when 'string' + return 'Date' if schema['format'] == 'date' + return 'Time' if schema['format'] == 'date-time' + 'String' + when 'integer' + 'Integer' + when 'number' + 'Float' + when 'boolean' + 'Boolean' + when 'object' + 'Object' + else + 'Object' + end + end + + def start_test_server_session + response = test_server_request( + :post, + '/__openapi_transformer__/sessions', + { + version: "v#{@api_version}", + feature: test_feature_name, + scenario: @scenario.name, + } + ) + @test_server_session = response.fetch('session') + Time.parse(response.fetch('frozen_at')) + end + + def stop_test_server_session + return unless @test_server_session + + test_server_request(:post, "/__openapi_transformer__/sessions/#{@test_server_session}/stop") + ensure + @test_server_session = nil + end + + def add_test_server_session(client) + if @test_server_session + client.default_headers['x-openapi-test-session'] = @test_server_session + end + client + end + + def test_feature_name + @test_feature_name ||= File.foreach(@scenario.location.file) do |line| + match = line.match(/^\s*Feature:\s*(.+?)\s*$/) + break match[1] if match + end + end + + def test_server_request(method, path, payload = nil) + uri = URI.join(ENV.fetch('DD_TEST_SERVER_URL') + '/', path.delete_prefix('/')) + request_class = method == :post ? Net::HTTP::Post : Net::HTTP::Get + request = request_class.new(uri) + if payload + request['content-type'] = 'application/json' + request.body = JSON.generate(payload) + end + response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http| + http.request(request) + end + unless response.is_a?(Net::HTTPSuccess) + raise "Test server #{method.upcase} #{path} failed (#{response.code}): #{response.body}" + end + response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body) + end +end + +World(TestRunnerWorld) + +module TestRunnerRequestNormalization + def call_api(http_method, path, opts = {}) + headers = opts.fetch(:header_params, {}) + if ENV['DD_TEST_SERVER_URL'] && + opts[:body].nil? && + opts.fetch(:form_params, {}).empty? && + !headers.key?('Content-Type') + headers['Content-Type'] = '' + end + super + end +end + +DatadogAPIClient::APIClient.prepend(TestRunnerRequestNormalization) diff --git a/run-bdd-tests.sh b/run-bdd-tests.sh new file mode 100755 index 000000000000..4de5ee2e48cc --- /dev/null +++ b/run-bdd-tests.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd -- "$(dirname "$0")" >/dev/null 2>&1 && pwd -P)" +TEST_ARTIFACTS=${BDD_TEST_ARTIFACTS:-${REPO_ROOT}/features/generated} +FEATURE_FILES="${TEST_ARTIFACTS}/test-runner-data/features" + +if [ ! -x "${TEST_ARTIFACTS}/test-server" ]; then + echo "Generated test server not found at ${TEST_ARTIFACTS}/test-server" >&2 + exit 1 +fi +if [ ! -f "${TEST_ARTIFACTS}/test-runner-data/manifest.json" ]; then + echo "Generated test runner manifest not found below ${TEST_ARTIFACTS}" >&2 + exit 1 +fi + +TEST_SERVER_PID="" +cleanup() { + if [ -n "${TEST_SERVER_PID}" ]; then + kill "${TEST_SERVER_PID}" 2>/dev/null || true + fi +} +trap cleanup EXIT + +TEST_SERVER_PORT=${BDD_TEST_SERVER_PORT:-18082} +TEST_SERVER_LOG=${BDD_TEST_SERVER_LOG:-${TMPDIR:-/tmp}/datadog-ruby-test-server.log} +"${TEST_ARTIFACTS}/test-server" --port "${TEST_SERVER_PORT}" >"${TEST_SERVER_LOG}" 2>&1 & +TEST_SERVER_PID=$! +for _ in {1..50}; do + if curl --silent --fail "http://127.0.0.1:${TEST_SERVER_PORT}/__openapi_transformer__/health" >/dev/null; then + break + fi + sleep 0.1 +done +curl --silent --fail "http://127.0.0.1:${TEST_SERVER_PORT}/__openapi_transformer__/health" >/dev/null + +cd "${REPO_ROOT}" +DD_TEST_SERVER_URL="http://127.0.0.1:${TEST_SERVER_PORT}" \ +DD_TEST_RUNNER_DATA="${TEST_ARTIFACTS}/test-runner-data" \ +RECORD=false \ +bundle exec cucumber \ + "${FEATURE_FILES}" \ + --tags 'not @integration-only' \ + --format progress \ + "$@"