Commit dcbf3514 authored by Paul Slaughter's avatar Paul Slaughter

Refactor snippets edit spec and small fixes

- With mock_apollo_client there's no
  reason to use `vm.setData`. This
  actually revealed a bug which is now
  fixed.
- Also improves error message handling.
parent f0daa643
...@@ -18,6 +18,7 @@ import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; ...@@ -18,6 +18,7 @@ import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
import { getSnippetMixin } from '../mixins/snippets'; import { getSnippetMixin } from '../mixins/snippets';
import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants'; import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants';
import { markBlobPerformance } from '../utils/blob'; import { markBlobPerformance } from '../utils/blob';
import { getErrorMessage } from '../utils/error';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue'; import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
...@@ -190,7 +191,10 @@ export default { ...@@ -190,7 +191,10 @@ export default {
} }
}) })
.catch((e) => { .catch((e) => {
this.flashAPIFailure(e); // eslint-disable-next-line no-console
console.error('[gitlab] unexpected error while updating snippet', e);
this.flashAPIFailure(getErrorMessage(e));
}); });
}, },
updateActions(actions) { updateActions(actions) {
......
...@@ -11,10 +11,14 @@ export const getSnippetMixin = { ...@@ -11,10 +11,14 @@ export const getSnippetMixin = {
ids: [this.snippetGid], ids: [this.snippetGid],
}; };
}, },
update: (data) => { update(data) {
const res = data.snippets.nodes[0]; const res = data.snippets.nodes[0];
// Set `snippet.blobs` since some child components are coupled to this.
if (res) { if (res) {
res.blobs = res.blobs.nodes; // It's possible for us to not get any blobs in a response.
// In this case, we should default to current blobs.
res.blobs = res.blobs ? res.blobs.nodes : this.blobs;
} }
return res; return res;
......
import { isString } from 'lodash';
import { __ } from '~/locale';
export const UNEXPECTED_ERROR = __('Unexpected error');
export const getErrorMessage = (e) => {
if (!e) {
return UNEXPECTED_ERROR;
}
if (isString(e)) {
return e;
}
return e.message || e.networkError || UNEXPECTED_ERROR;
};
---
title: Improve error message reporting in snippet create or update
merge_request: 53576
author:
type: other
...@@ -2,6 +2,7 @@ import VueApollo, { ApolloMutation } from 'vue-apollo'; ...@@ -2,6 +2,7 @@ import VueApollo, { ApolloMutation } from 'vue-apollo';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { merge } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
...@@ -22,162 +23,95 @@ import { ...@@ -22,162 +23,95 @@ import {
} from '~/snippets/constants'; } from '~/snippets/constants';
import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql';
import { testEntries } from '../test_utils'; import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils';
jest.mock('~/flash'); jest.mock('~/flash');
const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js']; const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js'];
const TEST_API_ERROR = 'Ufff'; const TEST_API_ERROR = new Error('TEST_API_ERROR');
const TEST_MUTATION_ERROR = 'Bummer'; const TEST_MUTATION_ERROR = 'Test mutation error';
const TEST_CAPTCHA_RESPONSE = 'i-got-a-captcha';
const TEST_CAPTCHA_SITE_KEY = 'abc123';
const TEST_ACTIONS = { const TEST_ACTIONS = {
NO_CONTENT: { NO_CONTENT: merge({}, testEntries.created.diff, { content: '' }),
...testEntries.created.diff, NO_PATH: merge({}, testEntries.created.diff, { filePath: '' }),
content: '', VALID: merge({}, testEntries.created.diff),
},
NO_PATH: {
...testEntries.created.diff,
filePath: '',
},
VALID: {
...testEntries.created.diff,
},
}; };
const TEST_WEB_URL = '/snippets/7'; const TEST_WEB_URL = '/snippets/7';
const TEST_SNIPPET_GID = 'gid://gitlab/PersonalSnippet/42';
const createTestSnippet = () => ({ const createSnippet = () =>
webUrl: TEST_WEB_URL, merge(createGQLSnippet(), {
id: 7, webUrl: TEST_WEB_URL,
title: 'Snippet Title', visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
description: 'Lorem ipsum snippet desc', });
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
const createQueryResponse = (obj = {}) =>
createGQLSnippetsQueryResponse([merge(createSnippet(), obj)]);
const createMutationResponse = (key, obj = {}) => ({
data: {
[key]: merge(
{
errors: [],
snippet: {
__typename: 'Snippet',
webUrl: TEST_WEB_URL,
},
spamLogId: null,
needsCaptchaResponse: false,
captchaSiteKey: null,
},
obj,
),
},
}); });
const createMutationResponseWithErrors = (key) =>
createMutationResponse(key, { errors: [TEST_MUTATION_ERROR] });
const createMutationResponseWithRecaptcha = (key) =>
createMutationResponse(key, {
errors: ['ignored captcha error message'],
needsCaptchaResponse: true,
captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
});
const getApiData = ({
id,
title = '',
description = '',
visibilityLevel = SNIPPET_VISIBILITY_PRIVATE,
} = {}) => ({
id,
title,
description,
visibilityLevel,
blobActions: [],
});
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Snippet Edit app', () => { describe('Snippet Edit app', () => {
let wrapper; let wrapper;
let fakeApollo; let getSpy;
const captchaSiteKey = 'abc123';
// Mutate spy receives a "key" so that we can:
// - Use the same spy whether we are creating or updating.
// - Build the correct response object
// - Assert which mutation was sent
let mutateSpy;
const relativeUrlRoot = '/foo/'; const relativeUrlRoot = '/foo/';
const originalRelativeUrlRoot = gon.relative_url_root; const originalRelativeUrlRoot = gon.relative_url_root;
const GetSnippetQuerySpy = jest.fn().mockResolvedValue({
data: { snippets: { nodes: [createTestSnippet()] } },
});
const mutationTypes = { beforeEach(() => {
RESOLVE: jest.fn().mockResolvedValue({ getSpy = jest.fn().mockResolvedValue(createQueryResponse());
data: {
updateSnippet: {
errors: [],
snippet: createTestSnippet(),
needsCaptchaResponse: null,
captchaSiteKey: null,
},
},
}),
RESOLVE_WITH_ERRORS: jest.fn().mockResolvedValue({
data: {
updateSnippet: {
errors: [TEST_MUTATION_ERROR],
snippet: createTestSnippet(),
needsCaptchaResponse: null,
captchaSiteKey: null,
},
createSnippet: {
errors: [TEST_MUTATION_ERROR],
snippet: null,
needsCaptchaResponse: null,
captchaSiteKey: null,
},
},
}),
// TODO: QUESTION - This has to be wrapped in a factory function in order for the mock to have
// the `mockResolvedValueOnce` counter properly cleared/reset between test `it` examples, by
// ensuring each one gets a fresh mock instance. It's apparently impossible/hard to manually
// clear/reset them (see https://github.com/facebook/jest/issues/7136). So, should
// we convert all the others to factory functions too, to be consistent? And/or move the whole
// `mutationTypes` declaration into a `beforeEach`? (not sure if that will still solve the
// mock reset problem though).
RESOLVE_WITH_NEEDS_CAPTCHA_RESPONSE: () =>
jest
.fn()
// NOTE: There may be a captcha-related error, but it is not used in the GraphQL/Vue flow,
// only a truthy 'needsCaptchaResponse' value is used to trigger the captcha modal showing.
.mockResolvedValueOnce({
data: {
createSnippet: {
errors: ['ignored captcha error message'],
snippet: null,
needsCaptchaResponse: true,
captchaSiteKey,
},
},
})
// After the captcha is solved and the modal is closed, the second form submission should
// be successful and return needsCaptchaResponse = false.
.mockResolvedValueOnce({
data: {
createSnippet: {
errors: ['ignored captcha error message'],
snippet: createTestSnippet(),
needsCaptchaResponse: false,
captchaSiteKey: null,
},
},
}),
REJECT: jest.fn().mockRejectedValue(TEST_API_ERROR),
};
function createComponent({ // See `mutateSpy` declaration comment for why we send a key
props = {}, mutateSpy = jest.fn().mockImplementation((key) => Promise.resolve(createMutationResponse(key)));
loading = false,
mutationRes = mutationTypes.RESOLVE,
selectedLevel = SNIPPET_VISIBILITY_PRIVATE,
withApollo = false,
} = {}) {
let componentData = {
mocks: {
$apollo: {
queries: {
snippet: { loading },
},
mutate: mutationRes,
},
},
};
if (withApollo) {
const localVue = createLocalVue();
localVue.use(VueApollo);
const requestHandlers = [[GetSnippetQuery, GetSnippetQuerySpy]];
fakeApollo = createMockApollo(requestHandlers);
componentData = {
localVue,
apolloProvider: fakeApollo,
};
}
wrapper = shallowMount(SnippetEditApp, {
...componentData,
stubs: {
ApolloMutation,
FormFooterActions,
CaptchaModal: stubComponent(CaptchaModal),
},
provide: {
selectedLevel,
},
propsData: {
snippetGid: 'gid://gitlab/PersonalSnippet/42',
markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar',
...props,
},
});
}
beforeEach(() => {
gon.relative_url_root = relativeUrlRoot; gon.relative_url_root = relativeUrlRoot;
jest.spyOn(urlUtils, 'redirectTo').mockImplementation(); jest.spyOn(urlUtils, 'redirectTo').mockImplementation();
}); });
...@@ -193,7 +127,6 @@ describe('Snippet Edit app', () => { ...@@ -193,7 +127,6 @@ describe('Snippet Edit app', () => {
const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]'); const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled')); const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
const clickSubmitBtn = () => wrapper.find('[data-testid="snippet-edit-form"]').trigger('submit'); const clickSubmitBtn = () => wrapper.find('[data-testid="snippet-edit-form"]').trigger('submit');
const triggerBlobActions = (actions) => findBlobActions().vm.$emit('actions', actions); const triggerBlobActions = (actions) => findBlobActions().vm.$emit('actions', actions);
const setUploadFilesHtml = (paths) => { const setUploadFilesHtml = (paths) => {
...@@ -201,56 +134,92 @@ describe('Snippet Edit app', () => { ...@@ -201,56 +134,92 @@ describe('Snippet Edit app', () => {
.map((path) => `<input name="files[]" value="${path}">`) .map((path) => `<input name="files[]" value="${path}">`)
.join(''); .join('');
}; };
const getApiData = ({
id,
title = '',
description = '',
visibilityLevel = SNIPPET_VISIBILITY_PRIVATE,
} = {}) => ({
id,
title,
description,
visibilityLevel,
blobActions: [],
});
const setTitle = (val) => wrapper.find(TitleField).vm.$emit('input', val); const setTitle = (val) => wrapper.find(TitleField).vm.$emit('input', val);
const setDescription = (val) => wrapper.find(SnippetDescriptionEdit).vm.$emit('input', val); const setDescription = (val) => wrapper.find(SnippetDescriptionEdit).vm.$emit('input', val);
// Ideally we wouldn't call this method directly, but we don't have a way to trigger const createComponent = ({ props = {}, selectedLevel = SNIPPET_VISIBILITY_PRIVATE } = {}) => {
// apollo responses yet. if (wrapper) {
const loadSnippet = (...nodes) => { throw new Error('wrapper already created');
if (nodes.length) {
wrapper.setData({
snippet: nodes[0],
newSnippet: false,
});
} else {
wrapper.setData({
newSnippet: true,
});
} }
const requestHandlers = [
[GetSnippetQuery, getSpy],
// See `mutateSpy` declaration comment for why we send a key
[UpdateSnippetMutation, (...args) => mutateSpy('updateSnippet', ...args)],
[CreateSnippetMutation, (...args) => mutateSpy('createSnippet', ...args)],
];
const apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(SnippetEditApp, {
apolloProvider,
localVue,
stubs: {
ApolloMutation,
FormFooterActions,
CaptchaModal: stubComponent(CaptchaModal),
},
provide: {
selectedLevel,
},
propsData: {
snippetGid: TEST_SNIPPET_GID,
markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar',
...props,
},
});
};
// Creates comopnent and waits for gql load
const createComponentAndLoad = async (...args) => {
createComponent(...args);
await waitForPromises();
};
// Creates loaded component and submits form
const createComponentAndSubmit = async (...args) => {
await createComponentAndLoad(...args);
clickSubmitBtn();
await waitForPromises();
}; };
describe('rendering', () => { describe('when loading', () => {
it('renders loader while the query is in flight', () => { it('renders loader', () => {
createComponent({ loading: true }); createComponent();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
}); });
});
it.each([[{}], [{ snippetGid: '' }]])( describe.each`
'should render all required components with %s', snippetGid | expectedQueries
(props) => { ${TEST_SNIPPET_GID} | ${[[{ ids: [TEST_SNIPPET_GID] }]]}
createComponent(props); ${''} | ${[]}
`('when loaded with snippetGid=$snippetGid', ({ snippetGid, expectedQueries }) => {
expect(wrapper.find(CaptchaModal).exists()).toBe(true); beforeEach(() => createComponentAndLoad({ props: { snippetGid } }));
expect(wrapper.find(TitleField).exists()).toBe(true);
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true); it(`queries with ${JSON.stringify(expectedQueries)}`, () => {
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true); expect(getSpy.mock.calls).toEqual(expectedQueries);
expect(wrapper.find(FormFooterActions).exists()).toBe(true); });
expect(findBlobActions().exists()).toBe(true);
}, it('should render components', () => {
); expect(wrapper.find(CaptchaModal).exists()).toBe(true);
expect(wrapper.find(TitleField).exists()).toBe(true);
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
expect(wrapper.find(FormFooterActions).exists()).toBe(true);
expect(findBlobActions().exists()).toBe(true);
});
it('should hide loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
});
describe('default', () => {
it.each` it.each`
title | actions | shouldDisable title | actions | shouldDisable
${''} | ${[]} | ${true} ${''} | ${[]} | ${true}
...@@ -260,11 +229,12 @@ describe('Snippet Edit app', () => { ...@@ -260,11 +229,12 @@ describe('Snippet Edit app', () => {
${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${true} ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${true}
${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false} ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${false}
`( `(
'should handle submit disable (title=$title, actions=$actions, shouldDisable=$shouldDisable)', 'should handle submit disable (title="$title", actions="$actions", shouldDisable="$shouldDisable")',
async ({ title, actions, shouldDisable }) => { async ({ title, actions, shouldDisable }) => {
createComponent(); getSpy.mockResolvedValue(createQueryResponse({ title }));
await createComponentAndLoad();
loadSnippet({ title });
triggerBlobActions(actions); triggerBlobActions(actions);
await nextTick(); await nextTick();
...@@ -274,244 +244,226 @@ describe('Snippet Edit app', () => { ...@@ -274,244 +244,226 @@ describe('Snippet Edit app', () => {
); );
it.each` it.each`
projectPath | snippetArg | expectation projectPath | snippetGid | expectation
${''} | ${[]} | ${urlUtils.joinPaths('/', relativeUrlRoot, '-', 'snippets')} ${''} | ${''} | ${urlUtils.joinPaths('/', relativeUrlRoot, '-', 'snippets')}
${'project/path'} | ${[]} | ${urlUtils.joinPaths('/', relativeUrlRoot, 'project/path/-', 'snippets')} ${'project/path'} | ${''} | ${urlUtils.joinPaths('/', relativeUrlRoot, 'project/path/-', 'snippets')}
${''} | ${[createTestSnippet()]} | ${TEST_WEB_URL} ${''} | ${TEST_SNIPPET_GID} | ${TEST_WEB_URL}
${'project/path'} | ${[createTestSnippet()]} | ${TEST_WEB_URL} ${'project/path'} | ${TEST_SNIPPET_GID} | ${TEST_WEB_URL}
`( `(
'should set cancel href when (projectPath=$projectPath, snippet=$snippetArg)', 'should set cancel href (projectPath="$projectPath", snippetGid="$snippetGid")',
async ({ projectPath, snippetArg, expectation }) => { async ({ projectPath, snippetGid, expectation }) => {
createComponent({ await createComponentAndLoad({
props: { projectPath }, props: {
projectPath,
snippetGid,
},
}); });
loadSnippet(...snippetArg);
await nextTick();
expect(findCancelButton().attributes('href')).toBe(expectation); expect(findCancelButton().attributes('href')).toBe(expectation);
}, },
); );
});
describe('functionality', () => {
it('does not fetch snippet when create a new snippet', async () => {
createComponent({ props: { snippetGid: '' }, withApollo: true });
jest.runOnlyPendingTimers();
await nextTick();
expect(GetSnippetQuerySpy).not.toHaveBeenCalled(); it.each([SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC])(
}); 'marks %s visibility by default',
async (visibility) => {
createComponent({
props: { snippetGid: '' },
selectedLevel: visibility,
});
describe('default visibility', () => { expect(wrapper.find(SnippetVisibilityEdit).props('value')).toBe(visibility);
it.each([SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC])( },
'marks %s visibility by default', );
async (visibility) => {
createComponent({
props: { snippetGid: '' },
selectedLevel: visibility,
});
expect(wrapper.vm.snippet.visibilityLevel).toEqual(visibility);
},
);
});
describe('form submission handling', () => { describe('form submission handling', () => {
it.each` it.each`
snippetArg | projectPath | uploadedFiles | input | mutation snippetGid | projectPath | uploadedFiles | input | mutationType
${[]} | ${'project/path'} | ${[]} | ${{ ...getApiData(), projectPath: 'project/path', uploadedFiles: [] }} | ${CreateSnippetMutation} ${''} | ${'project/path'} | ${[]} | ${{ ...getApiData(), projectPath: 'project/path', uploadedFiles: [] }} | ${'createSnippet'}
${[]} | ${''} | ${[]} | ${{ ...getApiData(), projectPath: '', uploadedFiles: [] }} | ${CreateSnippetMutation} ${''} | ${''} | ${[]} | ${{ ...getApiData(), projectPath: '', uploadedFiles: [] }} | ${'createSnippet'}
${[]} | ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData(), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} | ${CreateSnippetMutation} ${''} | ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData(), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} | ${'createSnippet'}
${[createTestSnippet()]} | ${'project/path'} | ${[]} | ${getApiData(createTestSnippet())} | ${UpdateSnippetMutation} ${TEST_SNIPPET_GID} | ${'project/path'} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'}
${[createTestSnippet()]} | ${''} | ${[]} | ${getApiData(createTestSnippet())} | ${UpdateSnippetMutation} ${TEST_SNIPPET_GID} | ${''} | ${[]} | ${getApiData(createSnippet())} | ${'updateSnippet'}
`( `(
'should submit mutation with (snippet=$snippetArg, projectPath=$projectPath, uploadedFiles=$uploadedFiles)', 'should submit mutation $mutationType (snippetGid=$snippetGid, projectPath=$projectPath, uploadedFiles=$uploadedFiles)',
async ({ snippetArg, projectPath, uploadedFiles, mutation, input }) => { async ({ snippetGid, projectPath, uploadedFiles, mutationType, input }) => {
createComponent({ await createComponentAndLoad({
props: { props: {
snippetGid,
projectPath, projectPath,
}, },
}); });
loadSnippet(...snippetArg);
setUploadFilesHtml(uploadedFiles); setUploadFilesHtml(uploadedFiles);
await nextTick(); await nextTick();
clickSubmitBtn(); clickSubmitBtn();
expect(mutationTypes.RESOLVE).toHaveBeenCalledWith({ expect(mutateSpy).toHaveBeenCalledTimes(1);
mutation, expect(mutateSpy).toHaveBeenCalledWith(mutationType, {
variables: { input,
input,
},
}); });
}, },
); );
it('should redirect to snippet view on successful mutation', async () => { it('should redirect to snippet view on successful mutation', async () => {
createComponent(); await createComponentAndSubmit();
loadSnippet(createTestSnippet());
clickSubmitBtn();
await waitForPromises();
expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL); expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL);
}); });
it.each` it.each`
snippetArg | projectPath | mutationRes | expectMessage snippetGid | projectPath | mutationRes | expectMessage
${[]} | ${'project/path'} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} ${''} | ${'project/path'} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`}
${[]} | ${''} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} ${''} | ${''} | ${createMutationResponseWithErrors('createSnippet')} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`}
${[]} | ${''} | ${mutationTypes.REJECT} | ${`Can't create snippet: ${TEST_API_ERROR}`} ${TEST_SNIPPET_GID} | ${'project/path'} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`}
${[createTestSnippet()]} | ${'project/path'} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`} ${TEST_SNIPPET_GID} | ${''} | ${createMutationResponseWithErrors('updateSnippet')} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`}
${[createTestSnippet()]} | ${''} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`}
`( `(
'should flash error with (snippet=$snippetArg, projectPath=$projectPath)', 'should flash error with (snippet=$snippetGid, projectPath=$projectPath)',
async ({ snippetArg, projectPath, mutationRes, expectMessage }) => { async ({ snippetGid, projectPath, mutationRes, expectMessage }) => {
createComponent({ mutateSpy.mockResolvedValue(mutationRes);
await createComponentAndSubmit({
props: { props: {
projectPath, projectPath,
snippetGid,
}, },
mutationRes,
}); });
loadSnippet(...snippetArg);
clickSubmitBtn();
await waitForPromises();
expect(urlUtils.redirectTo).not.toHaveBeenCalled(); expect(urlUtils.redirectTo).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(expectMessage); expect(Flash).toHaveBeenCalledWith(expectMessage);
}, },
); );
describe('with apollo network error', () => {
beforeEach(async () => {
jest.spyOn(console, 'error').mockImplementation();
mutateSpy.mockRejectedValue(TEST_API_ERROR);
await createComponentAndSubmit();
});
it('should not redirect', () => {
expect(urlUtils.redirectTo).not.toHaveBeenCalled();
});
it('should flash', () => {
// Apollo automatically wraps the resolver's error in a NetworkError
expect(Flash).toHaveBeenCalledWith(
`Can't update snippet: Network error: ${TEST_API_ERROR.message}`,
);
});
it('should console error', () => {
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledTimes(1);
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledWith(
'[gitlab] unexpected error while updating snippet',
expect.objectContaining({ message: `Network error: ${TEST_API_ERROR.message}` }),
);
});
});
describe('when needsCaptchaResponse is true', () => { describe('when needsCaptchaResponse is true', () => {
let modal; let modal;
let captchaResponse;
let mutationRes;
beforeEach(async () => { beforeEach(async () => {
mutationRes = mutationTypes.RESOLVE_WITH_NEEDS_CAPTCHA_RESPONSE(); mutateSpy
createComponent({ .mockResolvedValueOnce(createMutationResponseWithRecaptcha('updateSnippet'))
props: { .mockResolvedValueOnce(createMutationResponseWithErrors('updateSnippet'));
snippetGid: '',
projectPath: '',
},
mutationRes,
});
// await waitForPromises();
modal = findCaptchaModal();
loadSnippet(); await createComponentAndSubmit();
clickSubmitBtn(); modal = findCaptchaModal();
await waitForPromises();
mutateSpy.mockClear();
}); });
it('should display captcha modal', () => { it('should display captcha modal', () => {
expect(urlUtils.redirectTo).not.toHaveBeenCalled(); expect(urlUtils.redirectTo).not.toHaveBeenCalled();
expect(modal.props('needsCaptchaResponse')).toEqual(true); expect(modal.props()).toEqual({
expect(modal.props('captchaSiteKey')).toEqual(captchaSiteKey); needsCaptchaResponse: true,
}); captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
describe('when a non-empty captcha response is received', () => {
beforeEach(() => {
captchaResponse = 'xyz123';
}); });
});
it('sets needsCaptchaResponse to false', async () => { describe.each`
modal.vm.$emit('receivedCaptchaResponse', captchaResponse); response | expectedCalls
await nextTick(); ${null} | ${[]}
expect(modal.props('needsCaptchaResponse')).toEqual(false); ${TEST_CAPTCHA_RESPONSE} | ${[['updateSnippet', { input: { ...getApiData(createSnippet()), captchaResponse: TEST_CAPTCHA_RESPONSE } }]]}
}); `('when captcha response is $response', ({ response, expectedCalls }) => {
beforeEach(async () => {
modal.vm.$emit('receivedCaptchaResponse', response);
it('resubmits form with captchaResponse', async () => {
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
await nextTick(); await nextTick();
expect(mutationRes.mock.calls[1][0]).toEqual({
mutation: CreateSnippetMutation,
variables: {
input: {
...getApiData(),
captchaResponse,
projectPath: '',
uploadedFiles: [],
},
},
});
});
});
describe('when an empty captcha response is received ', () => {
beforeEach(() => {
captchaResponse = '';
}); });
it('sets needsCaptchaResponse to false', async () => { it('sets needsCaptchaResponse to false', () => {
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
await nextTick();
expect(modal.props('needsCaptchaResponse')).toEqual(false); expect(modal.props('needsCaptchaResponse')).toEqual(false);
}); });
it('does not resubmit form', async () => { it(`expected to call times = ${expectedCalls.length}`, () => {
modal.vm.$emit('receivedCaptchaResponse', captchaResponse); expect(mutateSpy.mock.calls).toEqual(expectedCalls);
await nextTick();
expect(mutationRes.mock.calls.length).toEqual(1);
}); });
}); });
}); });
}); });
});
describe('on before unload', () => { describe('on before unload', () => {
const caseNoActions = () => triggerBlobActions([]); it.each([
const caseEmptyAction = () => triggerBlobActions([testEntries.empty.diff]); ['there are no actions', false, () => triggerBlobActions([])],
const caseSomeActions = () => triggerBlobActions([testEntries.updated.diff]); ['there is an empty action', false, () => triggerBlobActions([testEntries.empty.diff])],
const caseTitleIsSet = () => { ['there are actions', true, () => triggerBlobActions([testEntries.updated.diff])],
caseEmptyAction(); [
setTitle('test'); 'the title is set',
}; true,
const caseDescriptionIsSet = () => { () => {
caseEmptyAction(); triggerBlobActions([testEntries.empty.diff]);
setDescription('test'); setTitle('test');
}; },
const caseClickSubmitBtn = () => { ],
caseSomeActions(); [
clickSubmitBtn(); 'the description is set',
}; true,
() => {
it.each` triggerBlobActions([testEntries.empty.diff]);
condition | expectPrevented | action setDescription('test');
${'there are no actions'} | ${false} | ${caseNoActions} },
${'there is an empty action'} | ${false} | ${caseEmptyAction} ],
${'there are actions'} | ${true} | ${caseSomeActions} [
${'the title is set'} | ${true} | ${caseTitleIsSet} 'the snippet is being saved',
${'the description is set'} | ${true} | ${caseDescriptionIsSet} false,
${'the snippet is being saved'} | ${false} | ${caseClickSubmitBtn} () => {
`( triggerBlobActions([testEntries.updated.diff]);
'handles before unload prevent when $condition (expectPrevented=$expectPrevented)', clickSubmitBtn();
({ expectPrevented, action }) => { },
createComponent(); ],
loadSnippet(); ])(
'handles before unload prevent when %s (expectPrevented=%s)',
async (_, expectPrevented, action) => {
await createComponentAndLoad({
props: {
snippetGid: '',
},
});
action(); action();
const event = new Event('beforeunload'); const event = new Event('beforeunload');
const returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); const returnValueSetter = jest.spyOn(event, 'returnValue', 'set');
window.dispatchEvent(event); window.dispatchEvent(event);
if (expectPrevented) { if (expectPrevented) {
expect(returnValueSetter).toHaveBeenCalledWith( expect(returnValueSetter).toHaveBeenCalledWith(
'Are you sure you want to lose unsaved changes?', 'Are you sure you want to lose unsaved changes?',
); );
} else { } else {
expect(returnValueSetter).not.toHaveBeenCalled(); expect(returnValueSetter).not.toHaveBeenCalled();
} }
}, },
); );
});
}); });
}); });
import { TEST_HOST } from 'helpers/test_constants';
import { import {
SNIPPET_BLOB_ACTION_CREATE, SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE, SNIPPET_BLOB_ACTION_UPDATE,
...@@ -8,6 +9,51 @@ import { ...@@ -8,6 +9,51 @@ import {
const CONTENT_1 = 'Lorem ipsum dolar\nSit amit\n\nGoodbye!\n'; const CONTENT_1 = 'Lorem ipsum dolar\nSit amit\n\nGoodbye!\n';
const CONTENT_2 = 'Lorem ipsum dolar sit amit.\n\nGoodbye!\n'; const CONTENT_2 = 'Lorem ipsum dolar sit amit.\n\nGoodbye!\n';
export const createGQLSnippet = () => ({
__typename: 'Snippet',
id: 7,
title: 'Snippet Title',
description: 'Lorem ipsum snippet desc',
descriptionHtml: '<p>Lorem ipsum snippet desc</p>',
createdAt: new Date(Date.now() - 1e6),
updatedAt: new Date(Date.now() - 1e3),
httpUrlToRepo: `${TEST_HOST}/repo`,
sshUrlToRepo: 'ssh://ssh.test/repo',
blobs: [],
userPermissions: {
__typename: 'SnippetPermissions',
adminSnippet: true,
updateSnippet: true,
},
project: {
__typename: 'Project',
fullPath: 'group/project',
webUrl: `${TEST_HOST}/group/project`,
},
author: {
__typename: 'User',
id: 1,
avatarUrl: `${TEST_HOST}/avatar.png`,
name: 'root',
username: 'root',
webUrl: `${TEST_HOST}/root`,
status: {
__typename: 'UserStatus',
emoji: '',
message: '',
},
},
});
export const createGQLSnippetsQueryResponse = (snippets) => ({
data: {
snippets: {
__typename: 'SnippetConnection',
nodes: snippets,
},
},
});
export const testEntries = { export const testEntries = {
created: { created: {
id: 'blob_1', id: 'blob_1',
......
import { getErrorMessage, UNEXPECTED_ERROR } from '~/snippets/utils/error';
describe('~/snippets/utils/error', () => {
describe('getErrorMessage', () => {
it.each`
input | output
${null} | ${UNEXPECTED_ERROR}
${'message'} | ${'message'}
${new Error('test message')} | ${'test message'}
${{ networkError: 'Network error: test message' }} | ${'Network error: test message'}
${{}} | ${UNEXPECTED_ERROR}
`('with $input, should return "$output"', ({ input, output }) => {
expect(getErrorMessage(input)).toBe(output);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment