From 6ef43e2aa1cad78daaed93eff1aebd6a4e7e18a6 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Fri, 22 Jan 2021 03:09:04 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- .../components/alert_mapping_builder.vue | 40 +-- .../components/alerts_settings_form.vue | 26 +- .../components/mocks/gitlabFields.json | 103 +++--- .../components/mocks/parsedMapping.json | 78 ++--- .../create_http_integration.mutation.graphql | 18 +- .../utils/mapping_transformations.js | 63 ++++ doc/api/oauth2.md | 30 +- doc/development/integrations/secure.md | 2 + .../incident_management/alert_notifications.md | 8 + .../3_create/snippet/add_file_to_snippet_spec.rb | 2 +- .../clone_push_pull_personal_snippet_spec.rb | 2 +- .../clone_push_pull_project_snippet_spec.rb | 2 +- .../snippet/delete_file_from_snippet_spec.rb | 2 +- .../alerts_settings_form_spec.js.snap | 98 ------ .../alerts_settings/alert_mapping_builder_spec.js | 93 ----- .../alerts_integrations_list_spec.js | 118 ------- .../alerts_settings/alerts_settings_form_spec.js | 351 ------------------- .../alerts_settings_wrapper_spec.js | 379 --------------------- .../alerts_settings_form_spec.js.snap | 98 ++++++ .../components/alert_mapping_builder_spec.js | 101 ++++++ .../components/alerts_integrations_list_spec.js | 118 +++++++ .../components/alerts_settings_form_spec.js | 368 ++++++++++++++++++++ .../components/alerts_settings_wrapper_spec.js | 379 +++++++++++++++++++++ .../components/mocks/apollo_mock.js | 123 +++++++ .../components/mocks/integrations.json | 38 +++ spec/frontend/alerts_settings/components/util.js | 24 ++ spec/frontend/alerts_settings/mocks/apollo_mock.js | 123 ------- .../alerts_settings/mocks/integrations.json | 38 --- spec/frontend/alerts_settings/util.js | 24 -- .../utils/mapping_transformations_spec.js | 80 +++++ 30 files changed, 1558 insertions(+), 1371 deletions(-) create mode 100644 app/assets/javascripts/alerts_settings/utils/mapping_transformations.js create mode 100644 doc/operations/incident_management/alert_notifications.md delete mode 100644 spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap delete mode 100644 spec/frontend/alerts_settings/alert_mapping_builder_spec.js delete mode 100644 spec/frontend/alerts_settings/alerts_integrations_list_spec.js delete mode 100644 spec/frontend/alerts_settings/alerts_settings_form_spec.js delete mode 100644 spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js create mode 100644 spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap create mode 100644 spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js create mode 100644 spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js create mode 100644 spec/frontend/alerts_settings/components/alerts_settings_form_spec.js create mode 100644 spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js create mode 100644 spec/frontend/alerts_settings/components/mocks/apollo_mock.js create mode 100644 spec/frontend/alerts_settings/components/mocks/integrations.json create mode 100644 spec/frontend/alerts_settings/components/util.js delete mode 100644 spec/frontend/alerts_settings/mocks/apollo_mock.js delete mode 100644 spec/frontend/alerts_settings/mocks/integrations.json delete mode 100644 spec/frontend/alerts_settings/util.js create mode 100644 spec/frontend/alerts_settings/utils/mapping_transformations_spec.js diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index c52e9f5c264..8a481db5cb6 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -13,6 +13,12 @@ import { s__, __ } from '~/locale'; // data format is defined and will be the same as mocked (maybe with some minor changes) // feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 import gitlabFieldsMock from './mocks/gitlabFields.json'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { + getMappingData, + getPayloadFields, + transformForSave, +} from '../utils/mapping_transformations'; export const i18n = { columns: { @@ -46,12 +52,12 @@ export default { }, }, props: { - payloadFields: { + parsedPayload: { type: Array, required: false, default: () => [], }, - mapping: { + savedMapping: { type: Array, required: false, default: () => [], @@ -63,27 +69,11 @@ export default { }; }, computed: { + payloadFields() { + return getPayloadFields(this.parsedPayload); + }, mappingData() { - return this.gitlabFields.map((gitlabField) => { - const mappingFields = this.payloadFields.filter(({ type }) => - type.some((t) => gitlabField.compatibleTypes.includes(t)), - ); - - const foundMapping = this.mapping.find( - ({ alertFieldName }) => alertFieldName === gitlabField.name, - ); - - const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; - - return { - mapping: payloadAlertPaths, - fallback: fallbackAlertPaths, - searchTerm: '', - fallbackSearchTerm: '', - mappingFields, - ...gitlabField, - }; - }); + return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping); }, }, methods: { @@ -91,6 +81,7 @@ export default { const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } }; Vue.set(this.gitlabFields, fieldIndex, updatedField); + this.$emit('onMappingUpdate', transformForSave(this.mappingData)); }, setSearchTerm(search = '', searchFieldKey, gitlabKey) { const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); @@ -99,7 +90,6 @@ export default { }, filterFields(searchTerm = '', fields) { const search = searchTerm.toLowerCase(); - return fields.filter((field) => field.label.toLowerCase().includes(search)); }, isSelected(fieldValue, mapping) { @@ -112,7 +102,9 @@ export default { ); }, getFieldValue({ label, type }) { - return `${label} (${type.join(__(' or '))})`; + const types = type.map((t) => capitalizeFirstCharacter(t.toLowerCase())).join(__(' or ')); + + return `${label} (${types})`; }, noResults(searchTerm, fields) { return !this.filterFields(searchTerm, fields).length; diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 1ae7f826ce6..3280e0424aa 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -152,6 +152,7 @@ export default { }, resetSamplePayloadConfirmed: false, customMapping: null, + mapping: [], parsingPayload: false, currentIntegration: null, }; @@ -199,10 +200,10 @@ export default { this.selectedIntegration === typeSet.http ); }, - mappingBuilderFields() { + parsedSamplePayload() { return this.customMapping?.samplePayload?.payloadAlerFields?.nodes; }, - mappingBuilderMapping() { + savedMapping() { return this.customMapping?.storedMapping?.nodes; }, hasSamplePayload() { @@ -255,9 +256,20 @@ export default { }, submit() { const { name, apiUrl } = this.integrationForm; + const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping + ? { + payloadAttributeMappings: this.mapping, + payloadExample: this.integrationTestPayload.json, + } + : {}; + const variables = this.selectedIntegration === typeSet.http - ? { name, active: this.active } + ? { + name, + active: this.active, + ...customMappingVariables, + } : { apiUrl, active: this.active }; const integrationPayload = { type: this.selectedIntegration, variables }; @@ -336,6 +348,9 @@ export default { this.integrationTestPayload.json = res?.samplePayload.body; }); }, + updateMapping(mapping) { + this.mapping = mapping; + }, }, }; @@ -541,8 +556,9 @@ export default { > {{ $options.i18n.integrationFormSteps.step5.intro }} diff --git a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json index ac559a30eda..e4d0e92a6f8 100644 --- a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json +++ b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json @@ -1,112 +1,123 @@ [ { - "name": "title", + "name": "TITLE", "label": "Title", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ], "numberOfFallbacks": 1 }, { - "name": "description", + "name": "DESCRIPTION", "label": "Description", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "startTime", + "name": "START_TIME", "label": "Start time", "type": [ - "DateTime" + "DATETIME" ], "compatibleTypes": [ - "Number", - "DateTime" + "NUMBER", + "DATETIME" ] }, { - "name": "service", + "name": "END_TIME", + "label": "End time", + "type": [ + "DATETIME" + ], + "compatibleTypes": [ + "NUMBER", + "DATETIME" + ] + }, + { + "name": "SERVICE", "label": "Service", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "monitoringTool", + "name": "MONITORING_TOOL", "label": "Monitoring tool", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "hosts", + "name": "HOSTS", "label": "Hosts", "type": [ - "String", - "Array" + "STRING", + "ARRAY" ], "compatibleTypes": [ - "String", - "Array", - "Number", - "DateTime" + "STRING", + "ARRAY", + "NUMBER", + "DATETIME" ] }, { - "name": "severity", + "name": "SEVERITY", "label": "Severity", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "fingerprint", + "name": "FINGERPRINT", "label": "Fingerprint", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "environment", + "name": "GITLAB_ENVIRONMENT_NAME", "label": "Environment", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] } ] diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json index 5326678155d..c1de0d6f0e0 100644 --- a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json +++ b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json @@ -4,95 +4,69 @@ "payloadAlerFields": { "nodes": [ { - "name": "dashboardId", + "path": ["dashboardId"], "label": "Dashboard Id", - "type": [ - "Number" - ] + "type": "STRING" }, { - "name": "evalMatches", + "path": ["evalMatches"], "label": "Eval Matches", - "type": [ - "Array" - ] + "type": "ARRAY" }, { - "name": "createdAt", + "path": ["createdAt"], "label": "Created At", - "type": [ - "DateTime" - ] + "type": "DATETIME" }, { - "name": "imageUrl", + "path": ["imageUrl"], "label": "Image Url", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "message", + "path": ["message"], "label": "Message", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "orgId", + "path": ["orgId"], "label": "Org Id", - "type": [ - "Number" - ] + "type": "STRING" }, { - "name": "panelId", + "path": ["panelId"], "label": "Panel Id", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "ruleId", + "path": ["ruleId"], "label": "Rule Id", - "type": [ - "Number" - ] + "type": "STRING" }, { - "name": "ruleName", + "path": ["ruleName"], "label": "Rule Name", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "ruleUrl", + "path": ["ruleUrl"], "label": "Rule Url", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "state", + "path": ["state"], "label": "State", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "title", + "path": ["title"], "label": "Title", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "tags", + "path": ["tags", "tag"], "label": "Tags", - "type": [ - "Object" - ] + "type": "STRING" } ] } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql index d1dacbad40a..f3fc10b4bd4 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -1,7 +1,21 @@ #import "../fragments/integration_item.fragment.graphql" -mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { - httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { +mutation createHttpIntegration( + $projectPath: ID! + $name: String! + $active: Boolean! + $payloadExample: JsonString + $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!] +) { + httpIntegrationCreate( + input: { + projectPath: $projectPath + name: $name + active: $active + payloadExample: $payloadExample + payloadAttributeMappings: $payloadAttributeMappings + } + ) { errors integration { ...IntegrationItem diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js new file mode 100644 index 00000000000..a7e43c93fbf --- /dev/null +++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js @@ -0,0 +1,63 @@ +/** + * Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any) + * creates an object in a form convenient to build UI && interact with it + * @param {Object} gitlabFields - structure describing GitLab alert fields + * @param {Object} payloadFields - parsed from sample JSON sample alert fields + * @param {Object} savedMapping - GitLab fields to parsed fields mapping + * + * @return {Object} mapping data for UI mapping builder + */ +export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { + return gitlabFields.map((gitlabField) => { + // find fields from payload that match gitlab alert field by type + const mappingFields = payloadFields.filter(({ type }) => + gitlabField.compatibleTypes.includes(type), + ); + + // find the mapping that was previously stored + const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name); + + const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; + + return { + mapping: payloadAlertPaths, + fallback: fallbackAlertPaths, + searchTerm: '', + fallbackSearchTerm: '', + mappingFields, + ...gitlabField, + }; + }); +}; + +/** + * Based on mapping data configured by the user creates an object in a format suitable for save on BE + * @param {Object} mappingData - structure describing mapping between GitLab fields and parsed payload fields + * + * @return {Object} mapping data to send to BE + */ +export const transformForSave = (mappingData) => { + return mappingData.reduce((acc, field) => { + const mapped = field.mappingFields.find(({ name }) => name === field.mapping); + if (mapped) { + const { path, type, label } = mapped; + acc.push({ + fieldName: field.name, + path, + type, + label, + }); + } + return acc; + }, []); +}; + +/** + * Adds `name` prop to each provided by BE parsed payload field + * @param {Object} payload - parsed sample payload + * + * @return {Object} same as input with an extra `name` property which basically serves as a key to make a match + */ +export const getPayloadFields = (payload) => { + return payload.map((field) => ({ ...field, name: field.path.join('_') })); +}; diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index a80a97890ba..9f20fd540a4 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -89,7 +89,7 @@ Before starting the flow, generate the `STATE`, the `CODE_VERIFIER` and the `COD `/oauth/authorize` page with the following query parameters: ```plaintext - https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=YOUR_UNIQUE_STATE_HASH&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256 + https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256 ``` This page asks the user to approve the request from the app to access their @@ -100,7 +100,7 @@ Before starting the flow, generate the `STATE`, the `CODE_VERIFIER` and the `COD The redirect includes the authorization `code`, for example: ```plaintext - https://example.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH + https://example.com/oauth/redirect?code=1234567890&state=STATE ``` 1. With the authorization `code` returned from the previous request (denoted as @@ -139,29 +139,31 @@ detailed flow description. The authorization code flow is essentially the same as [authorization code flow with PKCE](#authorization-code-with-proof-key-for-code-exchange-pkce), +Before starting the flow, generate the `STATE`. It is a value that can't be predicted +used by the client to maintain state between the request and callback. It should also +be used as a CSRF token. + 1. Request authorization code. To do that, you should redirect the user to the - `/oauth/authorize` endpoint with the following GET parameters: + `/oauth/authorize` page with the following query parameters: ```plaintext https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES ``` - This will ask the user to approve the applications access to their account - based on the scopes specified in `REQUESTED_SCOPES` and then redirect back to - the `REDIRECT_URI` you provided. The [scope parameter](https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes#requesting-particular-scopes) - is a space separated list of scopes you want to have access to (e.g. `scope=read_user+profile` - would request `read_user` and `profile` scopes). The redirect will - include the GET `code` parameter, for example: + This page asks the user to approve the request from the app to access their + account based on the scopes specified in `REQUESTED_SCOPES`. The user is then + redirected back to the specified `REDIRECT_URI`. The [scope parameter](https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes#requesting-particular-scopes) + is a space separated list of scopes associated with the user. + For example,`scope=read_user+profile` requests the `read_user` and `profile` scopes. + The redirect includes the authorization `code`, for example: ```plaintext https://example.com/oauth/redirect?code=1234567890&state=STATE ``` - You should then use `code` to request an access token. - -1. After you have the authorization code you can request an `access_token` using the - code. You can do that by using any HTTP client. In the following example, - we are using Ruby's `rest-client`: +1. With the authorization `code` returned from the previous request (shown as + `RETURNED_CODE` in the following example), you can request an `access_token`, with + any HTTP client. The following example uses Ruby's `rest-client`: ```ruby parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI' diff --git a/doc/development/integrations/secure.md b/doc/development/integrations/secure.md index fb9d894d203..1d5aced5869 100644 --- a/doc/development/integrations/secure.md +++ b/doc/development/integrations/secure.md @@ -260,6 +260,8 @@ When executing command lines, scanners should use the `debug` level to log the c For instance, the [bundler-audit](https://gitlab.com/gitlab-org/security-products/analyzers/bundler-audit) scanner uses the `debug` level to log the command line `bundle audit check --quiet`, and what `bundle audit` writes to the standard output. +If the command line fails, then it should be logged with the `error` log level; +this makes it possible to debug the problem without having to change the log level to `debug` and rerun the scanning job. #### common logutil package diff --git a/doc/operations/incident_management/alert_notifications.md b/doc/operations/incident_management/alert_notifications.md new file mode 100644 index 00000000000..6f3b329572b --- /dev/null +++ b/doc/operations/incident_management/alert_notifications.md @@ -0,0 +1,8 @@ +--- +redirect_to: 'paging.md' +--- + +This document was moved to [another location](paging.md). + + + diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb index 469335db5ab..604c98ca21e 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Multiple file snippet' do + describe 'Multiple file snippet', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:personal_snippet) do Resource::Snippet.fabricate_via_api! do |snippet| snippet.title = 'Personal snippet to add file to' diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb index a21c5d58aad..2940b2067a1 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Version control for personal snippets' do + describe 'Version control for personal snippets', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:new_file) { 'new_snippet_file' } let(:changed_content) { 'changes' } let(:commit_message) { 'Changes to snippets' } diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb index 4ce6c3fdcd3..b91424d5b65 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Version control for project snippets' do + describe 'Version control for project snippets', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:new_file) { 'new_snippet_file' } let(:changed_content) { 'changes' } let(:commit_message) { 'Changes to snippets' } diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb index 8002e95cf0d..5c0983dabb6 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Multiple file snippet' do + describe 'Multiple file snippet', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:personal_snippet) do Resource::Snippet.fabricate_via_api! do |snippet| snippet.title = 'Personal snippet to delete file from' diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap deleted file mode 100644 index ef68a6a2c32..00000000000 --- a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap +++ /dev/null @@ -1,98 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AlertsSettingsFormNew with default values renders the initial template 1`] = ` -"
-
Add new integrations
-
-
- - - - -
-
- -
-
-
-
- - - -
-
-
-
Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the GitLab documentation to learn more about configuring your endpoint. - -
- Webhook URL - -
-
- - -
- -
-
-
-
- Authorization key - -
-
- - -
- -
-
- -
- - - -
-
-
-
Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional). - - - -
-
- - -
-
-
-
-
" -`; diff --git a/spec/frontend/alerts_settings/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/alert_mapping_builder_spec.js deleted file mode 100644 index 5d48ff02e35..00000000000 --- a/spec/frontend/alerts_settings/alert_mapping_builder_spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue'; -import gitlabFields from '~/alerts_settings/components/mocks/gitlabFields.json'; -import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; - -describe('AlertMappingBuilder', () => { - let wrapper; - - function mountComponent() { - wrapper = shallowMount(AlertMappingBuilder, { - propsData: { - payloadFields: parsedMapping.samplePayload.payloadAlerFields.nodes, - mapping: parsedMapping.storedMapping.nodes, - }, - }); - } - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - beforeEach(() => { - mountComponent(); - }); - - const findColumnInRow = (row, column) => - wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column); - - it('renders column captions', () => { - expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle); - expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle); - expect(findColumnInRow(0, 3).text()).toContain(i18n.columns.fallbackKeyTitle); - - const fallbackColumnIcon = findColumnInRow(0, 3).find(GlIcon); - expect(fallbackColumnIcon.exists()).toBe(true); - expect(fallbackColumnIcon.attributes('name')).toBe('question'); - expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip); - }); - - it('renders disabled form input for each mapped field', () => { - gitlabFields.forEach((field, index) => { - const input = findColumnInRow(index + 1, 0).find(GlFormInput); - expect(input.attributes('value')).toBe(`${field.label} (${field.type.join(' or ')})`); - expect(input.attributes('disabled')).toBe(''); - }); - }); - - it('renders right arrow next to each input', () => { - gitlabFields.forEach((field, index) => { - const arrow = findColumnInRow(index + 1, 1).find('.right-arrow'); - expect(arrow.exists()).toBe(true); - }); - }); - - it('renders mapping dropdown for each field', () => { - gitlabFields.forEach(({ compatibleTypes }, index) => { - const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown); - const searchBox = dropdown.find(GlSearchBoxByType); - const dropdownItems = dropdown.findAll(GlDropdownItem); - const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const numberOfMappingOptions = nodes.filter(({ type }) => - type.some((t) => compatibleTypes.includes(t)), - ); - - expect(dropdown.exists()).toBe(true); - expect(searchBox.exists()).toBe(true); - expect(dropdownItems).toHaveLength(numberOfMappingOptions.length); - }); - }); - - it('renders fallback dropdown only for the fields that have fallback', () => { - gitlabFields.forEach(({ compatibleTypes, numberOfFallbacks }, index) => { - const dropdown = findColumnInRow(index + 1, 3).find(GlDropdown); - expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks)); - - if (numberOfFallbacks) { - const searchBox = dropdown.find(GlSearchBoxByType); - const dropdownItems = dropdown.findAll(GlDropdownItem); - const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const numberOfMappingOptions = nodes.filter(({ type }) => - type.some((t) => compatibleTypes.includes(t)), - ); - - expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks)); - expect(dropdownItems).toHaveLength(numberOfMappingOptions.length); - } - }); - }); -}); diff --git a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js deleted file mode 100644 index 5a3874d055b..00000000000 --- a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js +++ /dev/null @@ -1,118 +0,0 @@ -import { GlTable, GlIcon, GlButton } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; -import Tracking from '~/tracking'; -import AlertIntegrationsList, { - i18n, -} from '~/alerts_settings/components/alerts_integrations_list.vue'; -import { trackAlertIntegrationsViewsOptions } from '~/alerts_settings/constants'; - -const mockIntegrations = [ - { - id: '1', - active: true, - name: 'Integration 1', - type: 'HTTP endpoint', - }, - { - id: '2', - active: false, - name: 'Integration 2', - type: 'HTTP endpoint', - }, -]; - -describe('AlertIntegrationsList', () => { - let wrapper; - const { trigger: triggerIntersection } = useMockIntersectionObserver(); - - function mountComponent({ data = {}, props = {} } = {}) { - wrapper = mount(AlertIntegrationsList, { - data() { - return { ...data }; - }, - propsData: { - integrations: mockIntegrations, - ...props, - }, - stubs: { - GlIcon: true, - GlButton: true, - }, - }); - } - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - beforeEach(() => { - mountComponent(); - }); - - const findTableComponent = () => wrapper.find(GlTable); - const findTableComponentRows = () => wrapper.find(GlTable).findAll('table tbody tr'); - const finsStatusCell = () => wrapper.findAll('[data-testid="integration-activated-status"]'); - - it('renders a table', () => { - expect(findTableComponent().exists()).toBe(true); - }); - - it('renders an empty state when no integrations provided', () => { - mountComponent({ props: { integrations: [] } }); - expect(findTableComponent().text()).toContain(i18n.emptyState); - }); - - it('renders an an edit and delete button for each integration', () => { - expect(findTableComponent().findAll(GlButton).length).toBe(4); - }); - - it('renders an highlighted row when a current integration is selected to edit', () => { - mountComponent({ data: { currentIntegration: { id: '1' } } }); - expect(findTableComponentRows().at(0).classes()).toContain('gl-bg-blue-50'); - }); - - describe('integration status', () => { - it('enabled', () => { - const cell = finsStatusCell().at(0); - const activatedIcon = cell.find(GlIcon); - expect(cell.text()).toBe(i18n.status.enabled.name); - expect(activatedIcon.attributes('name')).toBe('check-circle-filled'); - expect(activatedIcon.attributes('title')).toBe(i18n.status.enabled.tooltip); - }); - - it('disabled', () => { - const cell = finsStatusCell().at(1); - const notActivatedIcon = cell.find(GlIcon); - expect(cell.text()).toBe(i18n.status.disabled.name); - expect(notActivatedIcon.attributes('name')).toBe('warning-solid'); - expect(notActivatedIcon.attributes('title')).toBe(i18n.status.disabled.tooltip); - }); - }); - - describe('Snowplow tracking', () => { - beforeEach(() => { - mountComponent(); - jest.spyOn(Tracking, 'event'); - }); - - it('should NOT track alert list page views when list is collapsed', () => { - triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: false } }); - - expect(Tracking.event).not.toHaveBeenCalled(); - }); - - it('should track alert list page views only once when list is expanded', () => { - triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); - triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); - triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); - - const { category, action } = trackAlertIntegrationsViewsOptions; - expect(Tracking.event).toHaveBeenCalledTimes(1); - expect(Tracking.event).toHaveBeenCalledWith(category, action); - }); - }); -}); diff --git a/spec/frontend/alerts_settings/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_spec.js deleted file mode 100644 index 21cdec6f94c..00000000000 --- a/spec/frontend/alerts_settings/alerts_settings_form_spec.js +++ /dev/null @@ -1,351 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { - GlForm, - GlFormSelect, - GlCollapse, - GlFormInput, - GlToggle, - GlFormTextarea, -} from '@gitlab/ui'; -import waitForPromises from 'helpers/wait_for_promises'; -import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; -import { defaultAlertSettingsConfig } from './util'; -import { typeSet } from '~/alerts_settings/constants'; - -describe('AlertsSettingsFormNew', () => { - let wrapper; - const mockToastShow = jest.fn(); - - const createComponent = ({ - data = {}, - props = {}, - multipleHttpIntegrationsCustomMapping = false, - } = {}) => { - wrapper = mount(AlertsSettingsForm, { - data() { - return { ...data }; - }, - propsData: { - loading: false, - canAddIntegration: true, - ...props, - }, - provide: { - glFeatures: { multipleHttpIntegrationsCustomMapping }, - ...defaultAlertSettingsConfig, - }, - mocks: { - $toast: { - show: mockToastShow, - }, - }, - }); - }; - - const findForm = () => wrapper.find(GlForm); - const findSelect = () => wrapper.find(GlFormSelect); - const findFormSteps = () => wrapper.find(GlCollapse); - const findFormFields = () => wrapper.findAll(GlFormInput); - const findFormToggle = () => wrapper.find(GlToggle); - const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`); - const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); - const findSubmitButton = () => wrapper.find(`[type = "submit"]`); - const findMultiSupportText = () => - wrapper.find(`[data-testid="multi-integrations-not-supported"]`); - const findJsonTestSubmit = () => wrapper.find(`[data-testid="integration-test-and-submit"]`); - const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`); - const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`); - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - describe('with default values', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the initial template', () => { - expect(wrapper.html()).toMatchSnapshot(); - }); - - it('render the initial form with only an integration type dropdown', () => { - expect(findForm().exists()).toBe(true); - expect(findSelect().exists()).toBe(true); - expect(findMultiSupportText().exists()).toBe(false); - expect(findFormSteps().attributes('visible')).toBeUndefined(); - }); - - it('shows the rest of the form when the dropdown is used', async () => { - const options = findSelect().findAll('option'); - await options.at(1).setSelected(); - - await wrapper.vm.$nextTick(); - - expect(findFormFields().at(0).isVisible()).toBe(true); - }); - - it('disables the dropdown and shows help text when multi integrations are not supported', async () => { - createComponent({ props: { canAddIntegration: false } }); - expect(findSelect().attributes('disabled')).toBe('disabled'); - expect(findMultiSupportText().exists()).toBe(true); - }); - - it('disabled the name input when the selected value is prometheus', async () => { - createComponent(); - const options = findSelect().findAll('option'); - await options.at(2).setSelected(); - - expect(findFormFields().at(0).attributes('disabled')).toBe('disabled'); - }); - }); - - describe('submitting integration form', () => { - it('allows for create-new-integration with the correct form values for HTTP', async () => { - createComponent(); - - const options = findSelect().findAll('option'); - await options.at(1).setSelected(); - - await findFormFields().at(0).setValue('Test integration'); - await findFormToggle().trigger('click'); - - await wrapper.vm.$nextTick(); - - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); - - findForm().trigger('submit'); - - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('create-new-integration')).toBeTruthy(); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: 'Test integration', active: true } }, - ]); - }); - - it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => { - createComponent(); - - const options = findSelect().findAll('option'); - await options.at(2).setSelected(); - - await findFormFields().at(0).setValue('Test integration'); - await findFormFields().at(1).setValue('https://test.com'); - await findFormToggle().trigger('click'); - - await wrapper.vm.$nextTick(); - - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); - - findForm().trigger('submit'); - - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('create-new-integration')).toBeTruthy(); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } }, - ]); - }); - - it('allows for update-integration with the correct form values for HTTP', async () => { - createComponent({ - data: { - selectedIntegration: typeSet.http, - currentIntegration: { id: '1', name: 'Test integration pre' }, - }, - props: { - loading: false, - }, - }); - - await findFormFields().at(0).setValue('Test integration post'); - await findFormToggle().trigger('click'); - - await wrapper.vm.$nextTick(); - - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); - - findForm().trigger('submit'); - - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('update-integration')).toBeTruthy(); - expect(wrapper.emitted('update-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: 'Test integration post', active: true } }, - ]); - }); - - it('allows for update-integration with the correct form values for PROMETHEUS', async () => { - createComponent({ - data: { - selectedIntegration: typeSet.prometheus, - currentIntegration: { id: '1', apiUrl: 'https://test-pre.com' }, - }, - props: { - loading: false, - }, - }); - - await findFormFields().at(0).setValue('Test integration'); - await findFormFields().at(1).setValue('https://test-post.com'); - await findFormToggle().trigger('click'); - - await wrapper.vm.$nextTick(); - - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); - - findForm().trigger('submit'); - - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('update-integration')).toBeTruthy(); - expect(wrapper.emitted('update-integration')[0]).toEqual([ - { type: typeSet.prometheus, variables: { apiUrl: 'https://test-post.com', active: true } }, - ]); - }); - }); - - describe('submitting the integration with a JSON test payload', () => { - beforeEach(() => { - createComponent({ - data: { - selectedIntegration: typeSet.http, - currentIntegration: { id: '1', name: 'Test' }, - active: true, - }, - props: { - loading: false, - }, - }); - }); - - it('should not allow a user to test invalid JSON', async () => { - jest.useFakeTimers(); - await findJsonTextArea().setValue('Invalid JSON'); - - jest.runAllTimers(); - await wrapper.vm.$nextTick(); - - expect(findJsonTestSubmit().exists()).toBe(true); - expect(findJsonTestSubmit().text()).toBe('Save and test payload'); - expect(findJsonTestSubmit().props('disabled')).toBe(true); - }); - - it('should allow for the form to be automatically saved if the test payload is successfully submitted', async () => { - jest.useFakeTimers(); - await findJsonTextArea().setValue('{ "value": "value" }'); - - jest.runAllTimers(); - await wrapper.vm.$nextTick(); - expect(findJsonTestSubmit().props('disabled')).toBe(false); - }); - }); - - describe('Test payload section for HTTP integration', () => { - beforeEach(() => { - createComponent({ - multipleHttpIntegrationsCustomMapping: true, - props: { - currentIntegration: { - type: typeSet.http, - }, - }, - }); - }); - - describe.each` - active | resetSamplePayloadConfirmed | disabled - ${true} | ${true} | ${undefined} - ${false} | ${true} | ${'disabled'} - ${true} | ${false} | ${'disabled'} - ${false} | ${false} | ${'disabled'} - `('', ({ active, resetSamplePayloadConfirmed, disabled }) => { - const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; - const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled'; - const activeState = active ? 'active' : 'not active'; - - it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => { - wrapper.setData({ - customMapping: { samplePayload: true }, - active, - resetSamplePayloadConfirmed, - }); - await wrapper.vm.$nextTick(); - expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled); - }); - }); - - describe('action buttons for sample payload', () => { - describe.each` - resetSamplePayloadConfirmed | samplePayload | caption - ${false} | ${true} | ${'Edit payload'} - ${true} | ${false} | ${'Submit payload'} - ${true} | ${true} | ${'Submit payload'} - ${false} | ${false} | ${'Submit payload'} - `('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => { - const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided'; - const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; - - it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { - wrapper.setData({ - selectedIntegration: typeSet.http, - customMapping: { samplePayload }, - resetSamplePayloadConfirmed, - }); - await wrapper.vm.$nextTick(); - expect(findActionBtn().text()).toBe(caption); - }); - }); - }); - - describe('Parsing payload', () => { - it('displays a toast message on successful parse', async () => { - jest.useFakeTimers(); - wrapper.setData({ - selectedIntegration: typeSet.http, - customMapping: { samplePayload: false }, - }); - await wrapper.vm.$nextTick(); - - findActionBtn().vm.$emit('click'); - jest.advanceTimersByTime(1000); - - await waitForPromises(); - - expect(mockToastShow).toHaveBeenCalledWith( - 'Sample payload has been parsed. You can now map the fields.', - ); - }); - }); - }); - - describe('Mapping builder section', () => { - describe.each` - featureFlag | integrationOption | visible - ${true} | ${1} | ${true} - ${true} | ${2} | ${false} - ${false} | ${1} | ${false} - ${false} | ${2} | ${false} - `('', ({ featureFlag, integrationOption, visible }) => { - const visibleMsg = visible ? 'is rendered' : 'is not rendered'; - const featureFlagMsg = featureFlag ? 'is enabled' : 'is disabled'; - const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; - - it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType}`, async () => { - createComponent({ multipleHttpIntegrationsCustomMapping: featureFlag }); - const options = findSelect().findAll('option'); - options.at(integrationOption).setSelected(); - await wrapper.vm.$nextTick(); - expect(findMappingBuilderSection().exists()).toBe(visible); - }); - }); - }); -}); diff --git a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js deleted file mode 100644 index 4d0732ca76c..00000000000 --- a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js +++ /dev/null @@ -1,379 +0,0 @@ -import VueApollo from 'vue-apollo'; -import { mount, createLocalVue } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; -import { GlLoadingIcon } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; -import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; -import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; -import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; -import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; -import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql'; -import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; -import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql'; -import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql'; -import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; -import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql'; -import { typeSet } from '~/alerts_settings/constants'; -import { - ADD_INTEGRATION_ERROR, - RESET_INTEGRATION_TOKEN_ERROR, - UPDATE_INTEGRATION_ERROR, - INTEGRATION_PAYLOAD_TEST_ERROR, - DELETE_INTEGRATION_ERROR, -} from '~/alerts_settings/utils/error_messages'; -import createFlash from '~/flash'; -import { defaultAlertSettingsConfig } from './util'; -import mockIntegrations from './mocks/integrations.json'; -import { - createHttpVariables, - updateHttpVariables, - createPrometheusVariables, - updatePrometheusVariables, - ID, - errorMsg, - getIntegrationsQueryResponse, - destroyIntegrationResponse, - integrationToDestroy, - destroyIntegrationResponseWithErrors, -} from './mocks/apollo_mock'; - -jest.mock('~/flash'); - -const localVue = createLocalVue(); - -describe('AlertsSettingsWrapper', () => { - let wrapper; - let fakeApollo; - let destroyIntegrationHandler; - useMockIntersectionObserver(); - - const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon); - const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr'); - - async function destroyHttpIntegration(localWrapper) { - await jest.runOnlyPendingTimers(); - await localWrapper.vm.$nextTick(); - - localWrapper - .find(IntegrationsList) - .vm.$emit('delete-integration', { id: integrationToDestroy.id }); - } - - async function awaitApolloDomMock() { - await wrapper.vm.$nextTick(); // kick off the DOM update - await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises) - await wrapper.vm.$nextTick(); // kick off the DOM update for flash - } - - const createComponent = ({ data = {}, provide = {}, loading = false } = {}) => { - wrapper = mount(AlertsSettingsWrapper, { - data() { - return { ...data }; - }, - provide: { - ...defaultAlertSettingsConfig, - ...provide, - }, - mocks: { - $apollo: { - mutate: jest.fn(), - query: jest.fn(), - queries: { - integrations: { - loading, - }, - }, - }, - }, - }); - }; - - function createComponentWithApollo({ - destroyHandler = jest.fn().mockResolvedValue(destroyIntegrationResponse), - } = {}) { - localVue.use(VueApollo); - destroyIntegrationHandler = destroyHandler; - - const requestHandlers = [ - [getIntegrationsQuery, jest.fn().mockResolvedValue(getIntegrationsQueryResponse)], - [destroyHttpIntegrationMutation, destroyIntegrationHandler], - ]; - - fakeApollo = createMockApollo(requestHandlers); - - wrapper = mount(AlertsSettingsWrapper, { - localVue, - apolloProvider: fakeApollo, - provide: { - ...defaultAlertSettingsConfig, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('rendered via default permissions', () => { - it('renders the GraphQL alerts integrations list and new form', () => { - createComponent(); - expect(wrapper.find(IntegrationsList).exists()).toBe(true); - expect(wrapper.find(AlertsSettingsForm).exists()).toBe(true); - }); - - it('uses a loading state inside the IntegrationsList table', () => { - createComponent({ - data: { integrations: {} }, - loading: true, - }); - expect(wrapper.find(IntegrationsList).exists()).toBe(true); - expect(findLoader().exists()).toBe(true); - }); - - it('renders the IntegrationsList table using the API data', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - expect(findLoader().exists()).toBe(false); - expect(findIntegrations()).toHaveLength(mockIntegrations.length); - }); - - it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, - }); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { - type: typeSet.http, - variables: createHttpVariables, - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: createHttpIntegrationMutation, - update: expect.anything(), - variables: createHttpVariables, - }); - }); - - it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { updateHttpIntegrationMutation: { integration: { id: '1' } } }, - }); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { - type: typeSet.http, - variables: updateHttpVariables, - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateHttpIntegrationMutation, - variables: updateHttpVariables, - }); - }); - - it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { resetHttpTokenMutation: { integration: { id: '1' } } }, - }); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { - type: typeSet.http, - variables: { id: ID }, - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: resetHttpTokenMutation, - variables: { - id: ID, - }, - }); - }); - - it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } }, - }); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { - type: typeSet.prometheus, - variables: createPrometheusVariables, - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: createPrometheusIntegrationMutation, - update: expect.anything(), - variables: createPrometheusVariables, - }); - }); - - it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } }, - }); - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { - type: typeSet.prometheus, - variables: updatePrometheusVariables, - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updatePrometheusIntegrationMutation, - variables: updatePrometheusVariables, - }); - }); - - it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ - data: { resetPrometheusTokenMutation: { integration: { id: '1' } } }, - }); - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { - type: typeSet.prometheus, - variables: { id: ID }, - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: resetPrometheusTokenMutation, - variables: { - id: ID, - }, - }); - }); - - it('shows an error alert when integration creation fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR); - wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {}); - - await waitForPromises(); - - expect(createFlash).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR }); - }); - - it('shows an error alert when integration token reset fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR); - - wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {}); - - await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); - }); - - it('shows an error alert when integration update fails ', async () => { - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); - - wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {}); - - await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); - }); - - it('shows an error alert when integration test payload fails ', async () => { - const mock = new AxiosMockAdapter(axios); - mock.onPost(/(.*)/).replyOnce(403); - createComponent({ - data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, - loading: false, - }); - - return wrapper.vm.validateAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { - expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); - expect(createFlash).toHaveBeenCalledTimes(1); - mock.restore(); - }); - }); - }); - - describe('with mocked Apollo client', () => { - it('has a selection of integrations loaded via the getIntegrationsQuery', async () => { - createComponentWithApollo(); - - await jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - expect(findIntegrations()).toHaveLength(4); - }); - - it('calls a mutation with correct parameters and destroys a integration', async () => { - createComponentWithApollo(); - - await destroyHttpIntegration(wrapper); - - expect(destroyIntegrationHandler).toHaveBeenCalled(); - - await wrapper.vm.$nextTick(); - - expect(findIntegrations()).toHaveLength(3); - }); - - it('displays flash if mutation had a recoverable error', async () => { - createComponentWithApollo({ - destroyHandler: jest.fn().mockResolvedValue(destroyIntegrationResponseWithErrors), - }); - - await destroyHttpIntegration(wrapper); - await awaitApolloDomMock(); - - expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); - }); - - it('displays flash if mutation had a non-recoverable error', async () => { - createComponentWithApollo({ - destroyHandler: jest.fn().mockRejectedValue('Error'), - }); - - await destroyHttpIntegration(wrapper); - await awaitApolloDomMock(); - - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_INTEGRATION_ERROR, - }); - }); - }); -}); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap new file mode 100644 index 00000000000..ef68a6a2c32 --- /dev/null +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertsSettingsFormNew with default values renders the initial template 1`] = ` +"
+
Add new integrations
+
+
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the GitLab documentation to learn more about configuring your endpoint. + +
+ Webhook URL + +
+
+ + +
+ +
+
+
+
+ Authorization key + +
+
+ + +
+ +
+
+ +
+ + + +
+
+
+
Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional). + + + +
+
+ + +
+
+
+
+
" +`; diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js new file mode 100644 index 00000000000..92ea8b2f33c --- /dev/null +++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js @@ -0,0 +1,101 @@ +import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue'; +import gitlabFields from '~/alerts_settings/components/mocks/gitlabFields.json'; +import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations'; + +describe('AlertMappingBuilder', () => { + let wrapper; + + function mountComponent() { + wrapper = shallowMount(AlertMappingBuilder, { + propsData: { + parsedPayload: parsedMapping.samplePayload.payloadAlerFields.nodes, + savedMapping: parsedMapping.storedMapping.nodes, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(() => { + mountComponent(); + }); + + const findColumnInRow = (row, column) => + wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column); + + it('renders column captions', () => { + expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle); + expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle); + expect(findColumnInRow(0, 3).text()).toContain(i18n.columns.fallbackKeyTitle); + + const fallbackColumnIcon = findColumnInRow(0, 3).find(GlIcon); + expect(fallbackColumnIcon.exists()).toBe(true); + expect(fallbackColumnIcon.attributes('name')).toBe('question'); + expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip); + }); + + it('renders disabled form input for each mapped field', () => { + gitlabFields.forEach((field, index) => { + const input = findColumnInRow(index + 1, 0).find(GlFormInput); + const types = field.type.map((t) => capitalizeFirstCharacter(t.toLowerCase())).join(' or '); + expect(input.attributes('value')).toBe(`${field.label} (${types})`); + expect(input.attributes('disabled')).toBe(''); + }); + }); + + it('renders right arrow next to each input', () => { + gitlabFields.forEach((field, index) => { + const arrow = findColumnInRow(index + 1, 1).find('.right-arrow'); + expect(arrow.exists()).toBe(true); + }); + }); + + it('renders mapping dropdown for each field', () => { + gitlabFields.forEach(({ compatibleTypes }, index) => { + const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown); + const searchBox = dropdown.findComponent(GlSearchBoxByType); + const dropdownItems = dropdown.findAllComponents(GlDropdownItem); + const { nodes } = parsedMapping.samplePayload.payloadAlerFields; + const mappingOptions = nodes.filter(({ type }) => compatibleTypes.includes(type)); + + expect(dropdown.exists()).toBe(true); + expect(searchBox.exists()).toBe(true); + expect(dropdownItems).toHaveLength(mappingOptions.length); + }); + }); + + it('renders fallback dropdown only for the fields that have fallback', () => { + gitlabFields.forEach(({ compatibleTypes, numberOfFallbacks }, index) => { + const dropdown = findColumnInRow(index + 1, 3).find(GlDropdown); + expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks)); + + if (numberOfFallbacks) { + const searchBox = dropdown.findComponent(GlSearchBoxByType); + const dropdownItems = dropdown.findAllComponents(GlDropdownItem); + const { nodes } = parsedMapping.samplePayload.payloadAlerFields; + const mappingOptions = nodes.filter(({ type }) => compatibleTypes.includes(type)); + + expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks)); + expect(dropdownItems).toHaveLength(mappingOptions.length); + } + }); + }); + + it('emits event with selected mapping', () => { + const mappingToSave = { fieldName: 'TITLE', mapping: 'PARSED_TITLE' }; + jest.spyOn(transformationUtils, 'transformForSave').mockReturnValue(mappingToSave); + const dropdown = findColumnInRow(1, 2).find(GlDropdown); + const option = dropdown.find(GlDropdownItem); + option.vm.$emit('click'); + expect(wrapper.emitted('onMappingUpdate')[0]).toEqual([mappingToSave]); + }); +}); diff --git a/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js new file mode 100644 index 00000000000..5a3874d055b --- /dev/null +++ b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js @@ -0,0 +1,118 @@ +import { GlTable, GlIcon, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; +import Tracking from '~/tracking'; +import AlertIntegrationsList, { + i18n, +} from '~/alerts_settings/components/alerts_integrations_list.vue'; +import { trackAlertIntegrationsViewsOptions } from '~/alerts_settings/constants'; + +const mockIntegrations = [ + { + id: '1', + active: true, + name: 'Integration 1', + type: 'HTTP endpoint', + }, + { + id: '2', + active: false, + name: 'Integration 2', + type: 'HTTP endpoint', + }, +]; + +describe('AlertIntegrationsList', () => { + let wrapper; + const { trigger: triggerIntersection } = useMockIntersectionObserver(); + + function mountComponent({ data = {}, props = {} } = {}) { + wrapper = mount(AlertIntegrationsList, { + data() { + return { ...data }; + }, + propsData: { + integrations: mockIntegrations, + ...props, + }, + stubs: { + GlIcon: true, + GlButton: true, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(() => { + mountComponent(); + }); + + const findTableComponent = () => wrapper.find(GlTable); + const findTableComponentRows = () => wrapper.find(GlTable).findAll('table tbody tr'); + const finsStatusCell = () => wrapper.findAll('[data-testid="integration-activated-status"]'); + + it('renders a table', () => { + expect(findTableComponent().exists()).toBe(true); + }); + + it('renders an empty state when no integrations provided', () => { + mountComponent({ props: { integrations: [] } }); + expect(findTableComponent().text()).toContain(i18n.emptyState); + }); + + it('renders an an edit and delete button for each integration', () => { + expect(findTableComponent().findAll(GlButton).length).toBe(4); + }); + + it('renders an highlighted row when a current integration is selected to edit', () => { + mountComponent({ data: { currentIntegration: { id: '1' } } }); + expect(findTableComponentRows().at(0).classes()).toContain('gl-bg-blue-50'); + }); + + describe('integration status', () => { + it('enabled', () => { + const cell = finsStatusCell().at(0); + const activatedIcon = cell.find(GlIcon); + expect(cell.text()).toBe(i18n.status.enabled.name); + expect(activatedIcon.attributes('name')).toBe('check-circle-filled'); + expect(activatedIcon.attributes('title')).toBe(i18n.status.enabled.tooltip); + }); + + it('disabled', () => { + const cell = finsStatusCell().at(1); + const notActivatedIcon = cell.find(GlIcon); + expect(cell.text()).toBe(i18n.status.disabled.name); + expect(notActivatedIcon.attributes('name')).toBe('warning-solid'); + expect(notActivatedIcon.attributes('title')).toBe(i18n.status.disabled.tooltip); + }); + }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + mountComponent(); + jest.spyOn(Tracking, 'event'); + }); + + it('should NOT track alert list page views when list is collapsed', () => { + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: false } }); + + expect(Tracking.event).not.toHaveBeenCalled(); + }); + + it('should track alert list page views only once when list is expanded', () => { + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); + + const { category, action } = trackAlertIntegrationsViewsOptions; + expect(Tracking.event).toHaveBeenCalledTimes(1); + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); +}); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js new file mode 100644 index 00000000000..4f8983b1aca --- /dev/null +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -0,0 +1,368 @@ +import { mount } from '@vue/test-utils'; +import { + GlForm, + GlFormSelect, + GlCollapse, + GlFormInput, + GlToggle, + GlFormTextarea, +} from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; +import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue'; +import { defaultAlertSettingsConfig } from './util'; +import { typeSet } from '~/alerts_settings/constants'; + +describe('AlertsSettingsFormNew', () => { + let wrapper; + const mockToastShow = jest.fn(); + + const createComponent = ({ + data = {}, + props = {}, + multipleHttpIntegrationsCustomMapping = false, + } = {}) => { + wrapper = mount(AlertsSettingsForm, { + data() { + return { ...data }; + }, + propsData: { + loading: false, + canAddIntegration: true, + ...props, + }, + provide: { + glFeatures: { multipleHttpIntegrationsCustomMapping }, + ...defaultAlertSettingsConfig, + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + }); + }; + + const findForm = () => wrapper.find(GlForm); + const findSelect = () => wrapper.find(GlFormSelect); + const findFormSteps = () => wrapper.find(GlCollapse); + const findFormFields = () => wrapper.findAll(GlFormInput); + const findFormToggle = () => wrapper.find(GlToggle); + const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`); + const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); + const findMappingBuilder = () => wrapper.findComponent(MappingBuilder); + const findSubmitButton = () => wrapper.find(`[type = "submit"]`); + const findMultiSupportText = () => + wrapper.find(`[data-testid="multi-integrations-not-supported"]`); + const findJsonTestSubmit = () => wrapper.find(`[data-testid="integration-test-and-submit"]`); + const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`); + const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const selectOptionAtIndex = async (index) => { + const options = findSelect().findAll('option'); + await options.at(index).setSelected(); + }; + + const enableIntegration = (index, value) => { + findFormFields().at(index).setValue(value); + findFormToggle().trigger('click'); + }; + + describe('with default values', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the initial template', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('render the initial form with only an integration type dropdown', () => { + expect(findForm().exists()).toBe(true); + expect(findSelect().exists()).toBe(true); + expect(findMultiSupportText().exists()).toBe(false); + expect(findFormSteps().attributes('visible')).toBeUndefined(); + }); + + it('shows the rest of the form when the dropdown is used', async () => { + await selectOptionAtIndex(1); + + expect(findFormFields().at(0).isVisible()).toBe(true); + }); + + it('disables the dropdown and shows help text when multi integrations are not supported', async () => { + createComponent({ props: { canAddIntegration: false } }); + expect(findSelect().attributes('disabled')).toBe('disabled'); + expect(findMultiSupportText().exists()).toBe(true); + }); + + it('disabled the name input when the selected value is prometheus', async () => { + createComponent(); + await selectOptionAtIndex(2); + + expect(findFormFields().at(0).attributes('disabled')).toBe('disabled'); + }); + }); + + describe('submitting integration form', () => { + describe('HTTP', () => { + it('create', async () => { + createComponent(); + + const integrationName = 'Test integration'; + await selectOptionAtIndex(1); + enableIntegration(0, integrationName); + + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); + + findForm().trigger('submit'); + + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { type: typeSet.http, variables: { name: integrationName, active: true } }, + ]); + }); + + it('create with custom mapping', async () => { + createComponent({ multipleHttpIntegrationsCustomMapping: true }); + + const integrationName = 'Test integration'; + await selectOptionAtIndex(1); + + enableIntegration(0, integrationName); + + const sampleMapping = { field: 'test' }; + findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping); + findForm().trigger('submit'); + + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { + type: typeSet.http, + variables: { + name: integrationName, + active: true, + payloadAttributeMappings: sampleMapping, + payloadExample: null, + }, + }, + ]); + }); + + it('update', () => { + createComponent({ + data: { + selectedIntegration: typeSet.http, + currentIntegration: { id: '1', name: 'Test integration pre' }, + }, + props: { + loading: false, + }, + }); + const updatedIntegrationName = 'Test integration post'; + enableIntegration(0, updatedIntegrationName); + + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); + + findForm().trigger('submit'); + + expect(wrapper.emitted('update-integration')[0]).toEqual([ + { type: typeSet.http, variables: { name: updatedIntegrationName, active: true } }, + ]); + }); + }); + + describe('PROMETHEUS', () => { + it('create', async () => { + createComponent(); + + await selectOptionAtIndex(2); + + const apiUrl = 'https://test.com'; + enableIntegration(1, apiUrl); + + findFormToggle().trigger('click'); + + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); + + findForm().trigger('submit'); + + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { type: typeSet.prometheus, variables: { apiUrl, active: true } }, + ]); + }); + + it('update', () => { + createComponent({ + data: { + selectedIntegration: typeSet.prometheus, + currentIntegration: { id: '1', apiUrl: 'https://test-pre.com' }, + }, + props: { + loading: false, + }, + }); + + const apiUrl = 'https://test-post.com'; + enableIntegration(1, apiUrl); + + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); + + findForm().trigger('submit'); + + expect(wrapper.emitted('update-integration')[0]).toEqual([ + { type: typeSet.prometheus, variables: { apiUrl, active: true } }, + ]); + }); + }); + }); + + describe('submitting the integration with a JSON test payload', () => { + beforeEach(() => { + createComponent({ + data: { + selectedIntegration: typeSet.http, + currentIntegration: { id: '1', name: 'Test' }, + active: true, + }, + props: { + loading: false, + }, + }); + }); + + it('should not allow a user to test invalid JSON', async () => { + jest.useFakeTimers(); + await findJsonTextArea().setValue('Invalid JSON'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + const jsonTestSubmit = findJsonTestSubmit(); + expect(jsonTestSubmit.exists()).toBe(true); + expect(jsonTestSubmit.text()).toBe('Save and test payload'); + expect(jsonTestSubmit.props('disabled')).toBe(true); + }); + + it('should allow for the form to be automatically saved if the test payload is successfully submitted', async () => { + jest.useFakeTimers(); + await findJsonTextArea().setValue('{ "value": "value" }'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + expect(findJsonTestSubmit().props('disabled')).toBe(false); + }); + }); + + describe('Test payload section for HTTP integration', () => { + beforeEach(() => { + createComponent({ + multipleHttpIntegrationsCustomMapping: true, + props: { + currentIntegration: { + type: typeSet.http, + }, + }, + }); + }); + + describe.each` + active | resetSamplePayloadConfirmed | disabled + ${true} | ${true} | ${undefined} + ${false} | ${true} | ${'disabled'} + ${true} | ${false} | ${'disabled'} + ${false} | ${false} | ${'disabled'} + `('', ({ active, resetSamplePayloadConfirmed, disabled }) => { + const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled'; + const activeState = active ? 'active' : 'not active'; + + it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => { + wrapper.setData({ + customMapping: { samplePayload: true }, + active, + resetSamplePayloadConfirmed, + }); + await wrapper.vm.$nextTick(); + expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled); + }); + }); + + describe('action buttons for sample payload', () => { + describe.each` + resetSamplePayloadConfirmed | samplePayload | caption + ${false} | ${true} | ${'Edit payload'} + ${true} | ${false} | ${'Submit payload'} + ${true} | ${true} | ${'Submit payload'} + ${false} | ${false} | ${'Submit payload'} + `('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => { + const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided'; + const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + + it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { + wrapper.setData({ + selectedIntegration: typeSet.http, + customMapping: { samplePayload }, + resetSamplePayloadConfirmed, + }); + await wrapper.vm.$nextTick(); + expect(findActionBtn().text()).toBe(caption); + }); + }); + }); + + describe('Parsing payload', () => { + it('displays a toast message on successful parse', async () => { + jest.useFakeTimers(); + wrapper.setData({ + selectedIntegration: typeSet.http, + customMapping: { samplePayload: false }, + }); + await wrapper.vm.$nextTick(); + + findActionBtn().vm.$emit('click'); + jest.advanceTimersByTime(1000); + + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith( + 'Sample payload has been parsed. You can now map the fields.', + ); + }); + }); + }); + + describe('Mapping builder section', () => { + describe.each` + featureFlag | integrationOption | visible + ${true} | ${1} | ${true} + ${true} | ${2} | ${false} + ${false} | ${1} | ${false} + ${false} | ${2} | ${false} + `('', ({ featureFlag, integrationOption, visible }) => { + const visibleMsg = visible ? 'is rendered' : 'is not rendered'; + const featureFlagMsg = featureFlag ? 'is enabled' : 'is disabled'; + const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; + + it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType}`, async () => { + createComponent({ multipleHttpIntegrationsCustomMapping: featureFlag }); + await selectOptionAtIndex(integrationOption); + + expect(findMappingBuilderSection().exists()).toBe(visible); + }); + }); + }); +}); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js new file mode 100644 index 00000000000..4d0732ca76c --- /dev/null +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -0,0 +1,379 @@ +import VueApollo from 'vue-apollo'; +import { mount, createLocalVue } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; +import { GlLoadingIcon } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; +import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; +import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; +import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; +import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql'; +import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; +import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql'; +import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql'; +import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; +import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql'; +import { typeSet } from '~/alerts_settings/constants'; +import { + ADD_INTEGRATION_ERROR, + RESET_INTEGRATION_TOKEN_ERROR, + UPDATE_INTEGRATION_ERROR, + INTEGRATION_PAYLOAD_TEST_ERROR, + DELETE_INTEGRATION_ERROR, +} from '~/alerts_settings/utils/error_messages'; +import createFlash from '~/flash'; +import { defaultAlertSettingsConfig } from './util'; +import mockIntegrations from './mocks/integrations.json'; +import { + createHttpVariables, + updateHttpVariables, + createPrometheusVariables, + updatePrometheusVariables, + ID, + errorMsg, + getIntegrationsQueryResponse, + destroyIntegrationResponse, + integrationToDestroy, + destroyIntegrationResponseWithErrors, +} from './mocks/apollo_mock'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('AlertsSettingsWrapper', () => { + let wrapper; + let fakeApollo; + let destroyIntegrationHandler; + useMockIntersectionObserver(); + + const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon); + const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr'); + + async function destroyHttpIntegration(localWrapper) { + await jest.runOnlyPendingTimers(); + await localWrapper.vm.$nextTick(); + + localWrapper + .find(IntegrationsList) + .vm.$emit('delete-integration', { id: integrationToDestroy.id }); + } + + async function awaitApolloDomMock() { + await wrapper.vm.$nextTick(); // kick off the DOM update + await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises) + await wrapper.vm.$nextTick(); // kick off the DOM update for flash + } + + const createComponent = ({ data = {}, provide = {}, loading = false } = {}) => { + wrapper = mount(AlertsSettingsWrapper, { + data() { + return { ...data }; + }, + provide: { + ...defaultAlertSettingsConfig, + ...provide, + }, + mocks: { + $apollo: { + mutate: jest.fn(), + query: jest.fn(), + queries: { + integrations: { + loading, + }, + }, + }, + }, + }); + }; + + function createComponentWithApollo({ + destroyHandler = jest.fn().mockResolvedValue(destroyIntegrationResponse), + } = {}) { + localVue.use(VueApollo); + destroyIntegrationHandler = destroyHandler; + + const requestHandlers = [ + [getIntegrationsQuery, jest.fn().mockResolvedValue(getIntegrationsQueryResponse)], + [destroyHttpIntegrationMutation, destroyIntegrationHandler], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = mount(AlertsSettingsWrapper, { + localVue, + apolloProvider: fakeApollo, + provide: { + ...defaultAlertSettingsConfig, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('rendered via default permissions', () => { + it('renders the GraphQL alerts integrations list and new form', () => { + createComponent(); + expect(wrapper.find(IntegrationsList).exists()).toBe(true); + expect(wrapper.find(AlertsSettingsForm).exists()).toBe(true); + }); + + it('uses a loading state inside the IntegrationsList table', () => { + createComponent({ + data: { integrations: {} }, + loading: true, + }); + expect(wrapper.find(IntegrationsList).exists()).toBe(true); + expect(findLoader().exists()).toBe(true); + }); + + it('renders the IntegrationsList table using the API data', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + expect(findLoader().exists()).toBe(false); + expect(findIntegrations()).toHaveLength(mockIntegrations.length); + }); + + it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { + type: typeSet.http, + variables: createHttpVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: createHttpIntegrationMutation, + update: expect.anything(), + variables: createHttpVariables, + }); + }); + + it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { updateHttpIntegrationMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { + type: typeSet.http, + variables: updateHttpVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateHttpIntegrationMutation, + variables: updateHttpVariables, + }); + }); + + it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { resetHttpTokenMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { + type: typeSet.http, + variables: { id: ID }, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: resetHttpTokenMutation, + variables: { + id: ID, + }, + }); + }); + + it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } }, + }); + wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', { + type: typeSet.prometheus, + variables: createPrometheusVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: createPrometheusIntegrationMutation, + update: expect.anything(), + variables: createPrometheusVariables, + }); + }); + + it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } }, + }); + wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', { + type: typeSet.prometheus, + variables: updatePrometheusVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updatePrometheusIntegrationMutation, + variables: updatePrometheusVariables, + }); + }); + + it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { resetPrometheusTokenMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { + type: typeSet.prometheus, + variables: { id: ID }, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: resetPrometheusTokenMutation, + variables: { + id: ID, + }, + }); + }); + + it('shows an error alert when integration creation fails ', async () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR); + wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {}); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR }); + }); + + it('shows an error alert when integration token reset fails ', async () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR); + + wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {}); + + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); + }); + + it('shows an error alert when integration update fails ', async () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); + + wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {}); + + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); + }); + + it('shows an error alert when integration test payload fails ', async () => { + const mock = new AxiosMockAdapter(axios); + mock.onPost(/(.*)/).replyOnce(403); + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + loading: false, + }); + + return wrapper.vm.validateAlertPayload({ endpoint: '', data: '', token: '' }).then(() => { + expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + expect(createFlash).toHaveBeenCalledTimes(1); + mock.restore(); + }); + }); + }); + + describe('with mocked Apollo client', () => { + it('has a selection of integrations loaded via the getIntegrationsQuery', async () => { + createComponentWithApollo(); + + await jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(findIntegrations()).toHaveLength(4); + }); + + it('calls a mutation with correct parameters and destroys a integration', async () => { + createComponentWithApollo(); + + await destroyHttpIntegration(wrapper); + + expect(destroyIntegrationHandler).toHaveBeenCalled(); + + await wrapper.vm.$nextTick(); + + expect(findIntegrations()).toHaveLength(3); + }); + + it('displays flash if mutation had a recoverable error', async () => { + createComponentWithApollo({ + destroyHandler: jest.fn().mockResolvedValue(destroyIntegrationResponseWithErrors), + }); + + await destroyHttpIntegration(wrapper); + await awaitApolloDomMock(); + + expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); + }); + + it('displays flash if mutation had a non-recoverable error', async () => { + createComponentWithApollo({ + destroyHandler: jest.fn().mockRejectedValue('Error'), + }); + + await destroyHttpIntegration(wrapper); + await awaitApolloDomMock(); + + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_INTEGRATION_ERROR, + }); + }); + }); +}); diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js new file mode 100644 index 00000000000..e0eba1e8421 --- /dev/null +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js @@ -0,0 +1,123 @@ +const projectPath = ''; +export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; +export const errorMsg = 'Something went wrong'; + +export const createHttpVariables = { + name: 'Test Pre', + active: true, + projectPath, +}; + +export const updateHttpVariables = { + name: 'Test Pre', + active: true, + id: ID, +}; + +export const createPrometheusVariables = { + apiUrl: 'https://test-pre.com', + active: true, + projectPath, +}; + +export const updatePrometheusVariables = { + apiUrl: 'https://test-pre.com', + active: true, + id: ID, +}; + +export const getIntegrationsQueryResponse = { + data: { + project: { + alertManagementIntegrations: { + nodes: [ + { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, + }, + { + id: '41', + type: 'HTTP', + active: true, + name: 'Test 9999', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-9999/b78a566e1776cfc2.json', + token: 'f7579aa03844e07af3b1f0fca3f79f81', + apiUrl: null, + }, + { + id: '40', + type: 'HTTP', + active: true, + name: 'Test 6', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-6/3e828ae28a240222.json', + token: '6536102a607a5dd74fcdde921f2349ee', + apiUrl: null, + }, + { + id: '12', + type: 'PROMETHEUS', + active: false, + name: 'Prometheus', + url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/prometheus/alerts/notify.json', + token: '256f687c6225aa5d6ee50c3d68120c4c', + apiUrl: 'https://localhost.ieeeesassadasasa', + }, + ], + }, + }, + }, +}; + +export const integrationToDestroy = { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, +}; + +export const destroyIntegrationResponse = { + data: { + httpIntegrationDestroy: { + errors: [], + integration: { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, + }, + }, + }, +}; + +export const destroyIntegrationResponseWithErrors = { + data: { + httpIntegrationDestroy: { + errors: ['Houston, we have a problem'], + integration: { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, + }, + }, + }, +}; diff --git a/spec/frontend/alerts_settings/components/mocks/integrations.json b/spec/frontend/alerts_settings/components/mocks/integrations.json new file mode 100644 index 00000000000..b1284fc55a2 --- /dev/null +++ b/spec/frontend/alerts_settings/components/mocks/integrations.json @@ -0,0 +1,38 @@ +[ + { + "id": "gid://gitlab/AlertManagement::HttpIntegration/7", + "type": "HTTP", + "active": true, + "name": "test", + "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/eddd36969b2d3d6a.json", + "token": "7eb24af194116411ec8d66b58c6b0d2e", + "apiUrl": null + }, + { + "id": "gid://gitlab/AlertManagement::HttpIntegration/6", + "type": "HTTP", + "active": false, + "name": "test", + "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/abce123.json", + "token": "8639e0ce06c731b00ee3e8dcdfd14fe0", + "apiUrl": null + }, + { + "id": "gid://gitlab/AlertManagement::HttpIntegration/5", + "type": "HTTP", + "active": false, + "name": "test", + "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/bcd64c85f918a2e2.json", + "token": "5c8101533d970a55d5c105f8abff2192", + "apiUrl": null + }, + { + "id": "gid://gitlab/PrometheusService/12", + "type": "PROMETHEUS", + "active": true, + "name": "Prometheus", + "url": "http://192.168.1.152:3000/root/autodevops/prometheus/alerts/notify.json", + "token": "0b18c37caa8fe980799b349916fe5ddf", + "apiUrl": "https://another-url-2.com" + } +] diff --git a/spec/frontend/alerts_settings/components/util.js b/spec/frontend/alerts_settings/components/util.js new file mode 100644 index 00000000000..5c07f22f1c9 --- /dev/null +++ b/spec/frontend/alerts_settings/components/util.js @@ -0,0 +1,24 @@ +const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; +const GENERIC_URL = '/alerts/notify.json'; +const KEY = 'abcedfg123'; +const INVALID_URL = 'http://invalid'; +const ACTIVE = false; + +export const defaultAlertSettingsConfig = { + generic: { + authorizationKey: KEY, + formPath: INVALID_URL, + url: GENERIC_URL, + alertsSetupUrl: INVALID_URL, + alertsUsageUrl: INVALID_URL, + active: ACTIVE, + }, + prometheus: { + authorizationKey: KEY, + prometheusFormPath: INVALID_URL, + url: PROMETHEUS_URL, + active: ACTIVE, + }, + projectPath: '', + multiIntegrations: true, +}; diff --git a/spec/frontend/alerts_settings/mocks/apollo_mock.js b/spec/frontend/alerts_settings/mocks/apollo_mock.js deleted file mode 100644 index e0eba1e8421..00000000000 --- a/spec/frontend/alerts_settings/mocks/apollo_mock.js +++ /dev/null @@ -1,123 +0,0 @@ -const projectPath = ''; -export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; -export const errorMsg = 'Something went wrong'; - -export const createHttpVariables = { - name: 'Test Pre', - active: true, - projectPath, -}; - -export const updateHttpVariables = { - name: 'Test Pre', - active: true, - id: ID, -}; - -export const createPrometheusVariables = { - apiUrl: 'https://test-pre.com', - active: true, - projectPath, -}; - -export const updatePrometheusVariables = { - apiUrl: 'https://test-pre.com', - active: true, - id: ID, -}; - -export const getIntegrationsQueryResponse = { - data: { - project: { - alertManagementIntegrations: { - nodes: [ - { - id: '37', - type: 'HTTP', - active: true, - name: 'Test 5', - url: - 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', - token: '89eb01df471d990ff5162a1c640408cf', - apiUrl: null, - }, - { - id: '41', - type: 'HTTP', - active: true, - name: 'Test 9999', - url: - 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-9999/b78a566e1776cfc2.json', - token: 'f7579aa03844e07af3b1f0fca3f79f81', - apiUrl: null, - }, - { - id: '40', - type: 'HTTP', - active: true, - name: 'Test 6', - url: - 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-6/3e828ae28a240222.json', - token: '6536102a607a5dd74fcdde921f2349ee', - apiUrl: null, - }, - { - id: '12', - type: 'PROMETHEUS', - active: false, - name: 'Prometheus', - url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/prometheus/alerts/notify.json', - token: '256f687c6225aa5d6ee50c3d68120c4c', - apiUrl: 'https://localhost.ieeeesassadasasa', - }, - ], - }, - }, - }, -}; - -export const integrationToDestroy = { - id: '37', - type: 'HTTP', - active: true, - name: 'Test 5', - url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', - token: '89eb01df471d990ff5162a1c640408cf', - apiUrl: null, -}; - -export const destroyIntegrationResponse = { - data: { - httpIntegrationDestroy: { - errors: [], - integration: { - id: '37', - type: 'HTTP', - active: true, - name: 'Test 5', - url: - 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', - token: '89eb01df471d990ff5162a1c640408cf', - apiUrl: null, - }, - }, - }, -}; - -export const destroyIntegrationResponseWithErrors = { - data: { - httpIntegrationDestroy: { - errors: ['Houston, we have a problem'], - integration: { - id: '37', - type: 'HTTP', - active: true, - name: 'Test 5', - url: - 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', - token: '89eb01df471d990ff5162a1c640408cf', - apiUrl: null, - }, - }, - }, -}; diff --git a/spec/frontend/alerts_settings/mocks/integrations.json b/spec/frontend/alerts_settings/mocks/integrations.json deleted file mode 100644 index b1284fc55a2..00000000000 --- a/spec/frontend/alerts_settings/mocks/integrations.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "id": "gid://gitlab/AlertManagement::HttpIntegration/7", - "type": "HTTP", - "active": true, - "name": "test", - "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/eddd36969b2d3d6a.json", - "token": "7eb24af194116411ec8d66b58c6b0d2e", - "apiUrl": null - }, - { - "id": "gid://gitlab/AlertManagement::HttpIntegration/6", - "type": "HTTP", - "active": false, - "name": "test", - "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/abce123.json", - "token": "8639e0ce06c731b00ee3e8dcdfd14fe0", - "apiUrl": null - }, - { - "id": "gid://gitlab/AlertManagement::HttpIntegration/5", - "type": "HTTP", - "active": false, - "name": "test", - "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/bcd64c85f918a2e2.json", - "token": "5c8101533d970a55d5c105f8abff2192", - "apiUrl": null - }, - { - "id": "gid://gitlab/PrometheusService/12", - "type": "PROMETHEUS", - "active": true, - "name": "Prometheus", - "url": "http://192.168.1.152:3000/root/autodevops/prometheus/alerts/notify.json", - "token": "0b18c37caa8fe980799b349916fe5ddf", - "apiUrl": "https://another-url-2.com" - } -] diff --git a/spec/frontend/alerts_settings/util.js b/spec/frontend/alerts_settings/util.js deleted file mode 100644 index 5c07f22f1c9..00000000000 --- a/spec/frontend/alerts_settings/util.js +++ /dev/null @@ -1,24 +0,0 @@ -const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; -const GENERIC_URL = '/alerts/notify.json'; -const KEY = 'abcedfg123'; -const INVALID_URL = 'http://invalid'; -const ACTIVE = false; - -export const defaultAlertSettingsConfig = { - generic: { - authorizationKey: KEY, - formPath: INVALID_URL, - url: GENERIC_URL, - alertsSetupUrl: INVALID_URL, - alertsUsageUrl: INVALID_URL, - active: ACTIVE, - }, - prometheus: { - authorizationKey: KEY, - prometheusFormPath: INVALID_URL, - url: PROMETHEUS_URL, - active: ACTIVE, - }, - projectPath: '', - multiIntegrations: true, -}; diff --git a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js new file mode 100644 index 00000000000..f725712fdd2 --- /dev/null +++ b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js @@ -0,0 +1,80 @@ +import { + getMappingData, + getPayloadFields, + transformForSave, +} from '~/alerts_settings/utils/mapping_transformations'; +import gitlabFieldsMock from '~/alerts_settings/components/mocks/gitlabFields.json'; +import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; + +describe('Mapping Transformation Utilities', () => { + const nameField = { + label: 'Name', + path: ['alert', 'name'], + type: 'STRING', + }; + const dashboardField = { + label: 'Dashboard Id', + path: ['alert', 'dashboardId'], + type: 'STRING', + }; + + describe('getMappingData', () => { + it('should return mapping data', () => { + const alertFields = gitlabFieldsMock.slice(0, 3); + const result = getMappingData( + alertFields, + getPayloadFields(parsedMapping.samplePayload.payloadAlerFields.nodes.slice(0, 3)), + parsedMapping.storedMapping.nodes.slice(0, 3), + ); + + result.forEach((data, index) => { + expect(data).toEqual( + expect.objectContaining({ + ...alertFields[index], + searchTerm: '', + fallbackSearchTerm: '', + }), + ); + }); + }); + }); + + describe('transformForSave', () => { + it('should transform mapped data for save', () => { + const fieldName = 'title'; + const mockMappingData = [ + { + name: fieldName, + mapping: 'alert_name', + mappingFields: getPayloadFields([dashboardField, nameField]), + }, + ]; + const result = transformForSave(mockMappingData); + const { path, type, label } = nameField; + expect(result).toEqual([{ fieldName, path, type, label }]); + }); + + it('should return empty array if no mapping provided', () => { + const fieldName = 'title'; + const mockMappingData = [ + { + name: fieldName, + mapping: null, + mappingFields: getPayloadFields([nameField, dashboardField]), + }, + ]; + const result = transformForSave(mockMappingData); + expect(result).toEqual([]); + }); + }); + + describe('getPayloadFields', () => { + it('should add name field to each payload field', () => { + const result = getPayloadFields([nameField, dashboardField]); + expect(result).toEqual([ + { ...nameField, name: 'alert_name' }, + { ...dashboardField, name: 'alert_dashboardId' }, + ]); + }); + }); +}); -- cgit v1.2.1