diff options
Diffstat (limited to 'spec/frontend/snippets')
14 files changed, 977 insertions, 528 deletions
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap index 959bc24eef6..1cf1ee74ddf 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap @@ -1,25 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = ` +exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = ` <div - class="form-group file-editor" + class="file-holder snippet" > - <label> - File - </label> + <blob-header-edit-stub + candelete="true" + data-qa-selector="file_name_field" + id="blob_local_7_file_path" + value="foo/bar/test.md" + /> - <div - class="file-holder snippet" - > - <blob-header-edit-stub - data-qa-selector="file_name_field" - value="lorem.txt" - /> - - <blob-content-edit-stub - filename="lorem.txt" - value="Lorem ipsum dolor sit amet, consectetur adipiscing elit." - /> - </div> + <blob-content-edit-stub + fileglobalid="blob_local_7" + filename="foo/bar/test.md" + value="Lorem ipsum dolar sit amet, +consectetur adipiscing elit." + /> </div> `; diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 297ad16b681..6020d595e3f 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -60,7 +60,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = <a aria-label="Leave zen mode" - class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" + class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" href="#" > <icon-stub diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index d2265dfd506..980855a0615 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -1,134 +1,157 @@ -import { shallowMount } from '@vue/test-utils'; -import Flash from '~/flash'; - +import { ApolloMutation } from 'vue-apollo'; import { GlLoadingIcon } from '@gitlab/ui'; -import { redirectTo } from '~/lib/utils/url_utility'; - +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import * as urlUtils from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue'; import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; -import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; +import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; import TitleField from '~/vue_shared/components/form/title.vue'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; -import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/snippets/constants'; - +import { SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants'; import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; - -import waitForPromises from 'helpers/wait_for_promises'; -import { ApolloMutation } from 'vue-apollo'; - -jest.mock('~/lib/utils/url_utility', () => ({ - redirectTo: jest.fn().mockName('redirectTo'), -})); +import { testEntries } from '../test_utils'; jest.mock('~/flash'); -let flashSpy; - -const rawProjectPathMock = '/project/path'; -const newlyEditedSnippetUrl = 'http://foo.bar'; -const apiError = { message: 'Ufff' }; -const mutationError = 'Bummer'; - -const attachedFilePath1 = 'foo/bar'; -const attachedFilePath2 = 'alpha/beta'; - -const actionWithContent = { - content: 'Foo Bar', -}; -const actionWithoutContent = { - content: '', -}; +const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js']; +const TEST_API_ERROR = 'Ufff'; +const TEST_MUTATION_ERROR = 'Bummer'; -const defaultProps = { - snippetGid: 'gid://gitlab/PersonalSnippet/42', - markdownPreviewPath: 'http://preview.foo.bar', - markdownDocsPath: 'http://docs.foo.bar', -}; -const defaultData = { - blobsActions: { - ...actionWithContent, - action: '', +const TEST_ACTIONS = { + NO_CONTENT: { + ...testEntries.created.diff, + content: '', + }, + NO_PATH: { + ...testEntries.created.diff, + filePath: '', + }, + VALID: { + ...testEntries.created.diff, }, }; +const TEST_WEB_URL = '/snippets/7'; + +const createTestSnippet = () => ({ + webUrl: TEST_WEB_URL, + id: 7, + title: 'Snippet Title', + description: 'Lorem ipsum snippet desc', + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, +}); + describe('Snippet Edit app', () => { let wrapper; - const resolveMutate = jest.fn().mockResolvedValue({ - data: { - updateSnippet: { - errors: [], - snippet: { - webUrl: newlyEditedSnippetUrl, + const mutationTypes = { + RESOLVE: jest.fn().mockResolvedValue({ + data: { + updateSnippet: { + errors: [], + snippet: createTestSnippet(), }, }, - }, - }); - - const resolveMutateWithErrors = jest.fn().mockResolvedValue({ - data: { - updateSnippet: { - errors: [mutationError], - snippet: { - webUrl: newlyEditedSnippetUrl, + }), + RESOLVE_WITH_ERRORS: jest.fn().mockResolvedValue({ + data: { + updateSnippet: { + errors: [TEST_MUTATION_ERROR], + snippet: createTestSnippet(), + }, + createSnippet: { + errors: [TEST_MUTATION_ERROR], + snippet: null, }, }, - createSnippet: { - errors: [mutationError], - snippet: null, - }, - }, - }); - - const rejectMutation = jest.fn().mockRejectedValue(apiError); - - const mutationTypes = { - RESOLVE: resolveMutate, - RESOLVE_WITH_ERRORS: resolveMutateWithErrors, - REJECT: rejectMutation, + }), + REJECT: jest.fn().mockRejectedValue(TEST_API_ERROR), }; function createComponent({ - props = defaultProps, - data = {}, + props = {}, loading = false, mutationRes = mutationTypes.RESOLVE, } = {}) { - const $apollo = { - queries: { - snippet: { - loading, - }, - }, - mutate: mutationRes, - }; + if (wrapper) { + throw new Error('wrapper already exists'); + } wrapper = shallowMount(SnippetEditApp, { - mocks: { $apollo }, + mocks: { + $apollo: { + queries: { + snippet: { loading }, + }, + mutate: mutationRes, + }, + }, stubs: { - FormFooterActions, ApolloMutation, + FormFooterActions, }, propsData: { + snippetGid: 'gid://gitlab/PersonalSnippet/42', + markdownPreviewPath: 'http://preview.foo.bar', + markdownDocsPath: 'http://docs.foo.bar', ...props, }, - data() { - return data; - }, }); - - flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure'); } + beforeEach(() => { + jest.spyOn(urlUtils, 'redirectTo').mockImplementation(); + }); + afterEach(() => { wrapper.destroy(); + wrapper = null; }); + const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit); const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]'); - const findCancellButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); + const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); + const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled')); + const clickSubmitBtn = () => wrapper.find('[data-testid="snippet-edit-form"]').trigger('submit'); + const triggerBlobActions = actions => findBlobActions().vm.$emit('actions', actions); + const setUploadFilesHtml = paths => { + wrapper.vm.$el.innerHTML = paths.map(path => `<input name="files[]" value="${path}">`).join(''); + }; + const getApiData = ({ + id, + title = '', + description = '', + visibilityLevel = SNIPPET_VISIBILITY_PRIVATE, + } = {}) => ({ + id, + title, + description, + visibilityLevel, + blobActions: [], + }); + + // Ideally we wouldn't call this method directly, but we don't have a way to trigger + // apollo responses yet. + const loadSnippet = (...edges) => { + if (edges.length) { + wrapper.setData({ + snippet: edges[0], + }); + } + + wrapper.vm.onSnippetFetch({ + data: { + snippets: { + edges, + }, + }, + }); + }; describe('rendering', () => { it('renders loader while the query is in flight', () => { @@ -136,295 +159,163 @@ describe('Snippet Edit app', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); - it('renders all required components', () => { - createComponent(); - - expect(wrapper.contains(TitleField)).toBe(true); - expect(wrapper.contains(SnippetDescriptionEdit)).toBe(true); - expect(wrapper.contains(SnippetBlobEdit)).toBe(true); - expect(wrapper.contains(SnippetVisibilityEdit)).toBe(true); - expect(wrapper.contains(FormFooterActions)).toBe(true); - }); - - it('does not fail if there is no snippet yet (new snippet creation)', () => { - const snippetGid = ''; - createComponent({ - props: { - ...defaultProps, - snippetGid, - }, - }); - - expect(wrapper.props('snippetGid')).toBe(snippetGid); - }); + it.each([[{}], [{ snippetGid: '' }]])( + 'should render all required components with %s', + props => { + createComponent(props); - it.each` - title | blobsActions | expectation - ${''} | ${{}} | ${true} - ${''} | ${{ actionWithContent }} | ${true} - ${''} | ${{ actionWithoutContent }} | ${true} - ${'foo'} | ${{}} | ${true} - ${'foo'} | ${{ actionWithoutContent }} | ${true} - ${'foo'} | ${{ actionWithoutContent, actionWithContent }} | ${true} - ${'foo'} | ${{ actionWithContent }} | ${false} - `( - 'disables submit button unless both title and content for all blobs are present', - ({ title, blobsActions, expectation }) => { - createComponent({ - data: { - snippet: { title }, - blobsActions, - }, - }); - const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled')); - expect(isBtnDisabled).toBe(expectation); + expect(wrapper.contains(TitleField)).toBe(true); + expect(wrapper.contains(SnippetDescriptionEdit)).toBe(true); + expect(wrapper.contains(SnippetVisibilityEdit)).toBe(true); + expect(wrapper.contains(FormFooterActions)).toBe(true); + expect(findBlobActions().exists()).toBe(true); }, ); it.each` - isNew | status | expectation - ${true} | ${`new`} | ${`/snippets`} - ${false} | ${`existing`} | ${newlyEditedSnippetUrl} - `('sets correct href for the cancel button on a $status snippet', ({ isNew, expectation }) => { - createComponent({ - data: { - snippet: { webUrl: newlyEditedSnippetUrl }, - newSnippet: isNew, - }, - }); + title | actions | shouldDisable + ${''} | ${[]} | ${true} + ${''} | ${[TEST_ACTIONS.VALID]} | ${true} + ${'foo'} | ${[]} | ${false} + ${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false} + ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${true} + ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${true} + `( + 'should handle submit disable (title=$title, actions=$actions, shouldDisable=$shouldDisable)', + async ({ title, actions, shouldDisable }) => { + createComponent(); - expect(findCancellButton().attributes('href')).toBe(expectation); - }); - }); + loadSnippet({ title }); + triggerBlobActions(actions); - describe('functionality', () => { - describe('form submission handling', () => { - it('does not submit unchanged blobs', () => { - const foo = { - action: '', - }; - const bar = { - action: 'update', - }; - createComponent({ - data: { - blobsActions: { - foo, - bar, - }, - }, - }); - clickSubmitBtn(); + await wrapper.vm.$nextTick(); - return waitForPromises().then(() => { - expect(resolveMutate).toHaveBeenCalledWith( - expect.objectContaining({ variables: { input: { files: [bar] } } }), - ); - }); - }); + expect(hasDisabledSubmit()).toBe(shouldDisable); + }, + ); - it.each` - newSnippet | projectPath | mutation | mutationName - ${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'} - ${true} | ${''} | ${CreateSnippetMutation} | ${'CreateSnippetMutation without projectPath'} - ${false} | ${rawProjectPathMock} | ${UpdateSnippetMutation} | ${'UpdateSnippetMutation with projectPath'} - ${false} | ${''} | ${UpdateSnippetMutation} | ${'UpdateSnippetMutation without projectPath'} - `('should submit $mutationName correctly', ({ newSnippet, projectPath, mutation }) => { + it.each` + projectPath | snippetArg | expectation + ${''} | ${[]} | ${'/-/snippets'} + ${'project/path'} | ${[]} | ${'/project/path/-/snippets'} + ${''} | ${[createTestSnippet()]} | ${TEST_WEB_URL} + ${'project/path'} | ${[createTestSnippet()]} | ${TEST_WEB_URL} + `( + 'should set cancel href when (projectPath=$projectPath, snippet=$snippetArg)', + async ({ projectPath, snippetArg, expectation }) => { createComponent({ - data: { - newSnippet, - ...defaultData, - }, - props: { - ...defaultProps, - projectPath, - }, + props: { projectPath }, }); - const mutationPayload = { - mutation, - variables: { - input: newSnippet ? expect.objectContaining({ projectPath }) : expect.any(Object), - }, - }; - - clickSubmitBtn(); - - expect(resolveMutate).toHaveBeenCalledWith(mutationPayload); - }); + loadSnippet(...snippetArg); - it('redirects to snippet view on successful mutation', () => { - createComponent(); - clickSubmitBtn(); + await wrapper.vm.$nextTick(); - return waitForPromises().then(() => { - expect(redirectTo).toHaveBeenCalledWith(newlyEditedSnippetUrl); - }); - }); + expect(findCancelButton().attributes('href')).toBe(expectation); + }, + ); + }); + describe('functionality', () => { + describe('form submission handling', () => { it.each` - newSnippet | projectPath | mutationName - ${true} | ${rawProjectPathMock} | ${'CreateSnippetMutation with projectPath'} - ${true} | ${''} | ${'CreateSnippetMutation without projectPath'} - ${false} | ${rawProjectPathMock} | ${'UpdateSnippetMutation with projectPath'} - ${false} | ${''} | ${'UpdateSnippetMutation without projectPath'} + snippetArg | projectPath | uploadedFiles | input | mutation + ${[]} | ${'project/path'} | ${[]} | ${{ ...getApiData(), projectPath: 'project/path', uploadedFiles: [] }} | ${CreateSnippetMutation} + ${[]} | ${''} | ${[]} | ${{ ...getApiData(), projectPath: '', uploadedFiles: [] }} | ${CreateSnippetMutation} + ${[]} | ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData(), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} | ${CreateSnippetMutation} + ${[createTestSnippet()]} | ${'project/path'} | ${[]} | ${getApiData(createTestSnippet())} | ${UpdateSnippetMutation} + ${[createTestSnippet()]} | ${''} | ${[]} | ${getApiData(createTestSnippet())} | ${UpdateSnippetMutation} `( - 'does not redirect to snippet view if the seemingly successful' + - ' $mutationName response contains errors', - ({ newSnippet, projectPath }) => { + 'should submit mutation with (snippet=$snippetArg, projectPath=$projectPath, uploadedFiles=$uploadedFiles)', + async ({ snippetArg, projectPath, uploadedFiles, mutation, input }) => { createComponent({ - data: { - newSnippet, - }, props: { - ...defaultProps, projectPath, }, - mutationRes: mutationTypes.RESOLVE_WITH_ERRORS, }); + loadSnippet(...snippetArg); + setUploadFilesHtml(uploadedFiles); + + await wrapper.vm.$nextTick(); clickSubmitBtn(); - return waitForPromises().then(() => { - expect(redirectTo).not.toHaveBeenCalled(); - expect(flashSpy).toHaveBeenCalledWith(mutationError); + expect(mutationTypes.RESOLVE).toHaveBeenCalledWith({ + mutation, + variables: { + input, + }, }); }, ); - it('flashes an error if mutation failed', () => { - createComponent({ - mutationRes: mutationTypes.REJECT, - }); + it('should redirect to snippet view on successful mutation', async () => { + createComponent(); + loadSnippet(createTestSnippet()); clickSubmitBtn(); - return waitForPromises().then(() => { - expect(redirectTo).not.toHaveBeenCalled(); - expect(flashSpy).toHaveBeenCalledWith(apiError); - }); + await waitForPromises(); + + expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL); }); it.each` - isNew | status | expectation - ${true} | ${`new`} | ${SNIPPET_CREATE_MUTATION_ERROR.replace('%{err}', '')} - ${false} | ${`existing`} | ${SNIPPET_UPDATE_MUTATION_ERROR.replace('%{err}', '')} + snippetArg | projectPath | mutationRes | expectMessage + ${[]} | ${'project/path'} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} + ${[]} | ${''} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} + ${[]} | ${''} | ${mutationTypes.REJECT} | ${`Can't create snippet: ${TEST_API_ERROR}`} + ${[createTestSnippet()]} | ${'project/path'} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`} + ${[createTestSnippet()]} | ${''} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`} `( - `renders the correct error message if mutation fails for $status snippet`, - ({ isNew, expectation }) => { + 'should flash error with (snippet=$snippetArg, projectPath=$projectPath)', + async ({ snippetArg, projectPath, mutationRes, expectMessage }) => { createComponent({ - data: { - newSnippet: isNew, + props: { + projectPath, }, - mutationRes: mutationTypes.REJECT, + mutationRes, }); + loadSnippet(...snippetArg); clickSubmitBtn(); - return waitForPromises().then(() => { - expect(Flash).toHaveBeenCalledWith(expect.stringContaining(expectation)); - }); + await waitForPromises(); + + expect(urlUtils.redirectTo).not.toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledWith(expectMessage); }, ); }); - describe('correctly includes attached files into the mutation', () => { - const createMutationPayload = expectation => { - return expect.objectContaining({ - variables: { - input: expect.objectContaining({ uploadedFiles: expectation }), - }, - }); - }; - - const updateMutationPayload = () => { - return expect.objectContaining({ - variables: { - input: expect.not.objectContaining({ uploadedFiles: expect.anything() }), - }, - }); - }; - - it.each` - paths | expectation - ${[attachedFilePath1]} | ${[attachedFilePath1]} - ${[attachedFilePath1, attachedFilePath2]} | ${[attachedFilePath1, attachedFilePath2]} - ${[]} | ${[]} - `(`correctly sends paths for $paths.length files`, ({ paths, expectation }) => { - createComponent({ - data: { - newSnippet: true, - }, - }); - - const fixtures = paths.map(path => { - return path ? `<input name="files[]" value="${path}">` : undefined; - }); - wrapper.vm.$el.innerHTML += fixtures.join(''); - - clickSubmitBtn(); - - expect(resolveMutate).toHaveBeenCalledWith(createMutationPayload(expectation)); - }); - - it(`neither fails nor sends 'uploadedFiles' to update mutation`, () => { - createComponent(); - - clickSubmitBtn(); - expect(resolveMutate).toHaveBeenCalledWith(updateMutationPayload()); - }); - }); - describe('on before unload', () => { - let event; - let returnValueSetter; - - const bootstrap = data => { - createComponent({ - data, - }); - - event = new Event('beforeunload'); - returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); - }; - - it('does not prevent page navigation if there are no blobs', () => { - bootstrap(); - window.dispatchEvent(event); - - expect(returnValueSetter).not.toHaveBeenCalled(); - }); - - it('does not prevent page navigation if there are no changes to the blobs content', () => { - bootstrap({ - blobsActions: { - foo: { - ...actionWithContent, - action: '', - }, - }, - }); - window.dispatchEvent(event); + it.each` + condition | expectPrevented | action + ${'there are no actions'} | ${false} | ${() => triggerBlobActions([])} + ${'there are actions'} | ${true} | ${() => triggerBlobActions([testEntries.updated.diff])} + ${'the snippet is being saved'} | ${false} | ${() => clickSubmitBtn()} + `( + 'handles before unload prevent when $condition (expectPrevented=$expectPrevented)', + ({ expectPrevented, action }) => { + createComponent(); + loadSnippet(); - expect(returnValueSetter).not.toHaveBeenCalled(); - }); + action(); - it('prevents page navigation if there are some changes in the snippet content', () => { - bootstrap({ - blobsActions: { - foo: { - ...actionWithContent, - action: 'update', - }, - }, - }); + const event = new Event('beforeunload'); + const returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); - window.dispatchEvent(event); + window.dispatchEvent(event); - expect(returnValueSetter).toHaveBeenCalledWith( - 'Are you sure you want to lose unsaved changes?', - ); - }); + if (expectPrevented) { + expect(returnValueSetter).toHaveBeenCalledWith( + 'Are you sure you want to lose unsaved changes?', + ); + } else { + expect(returnValueSetter).not.toHaveBeenCalled(); + } + }, + ); }); }); }); diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index b5446e70028..8cccbb83d54 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -1,19 +1,27 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; +import { shallowMount } from '@vue/test-utils'; import SnippetApp from '~/snippets/components/show.vue'; import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import SnippetHeader from '~/snippets/components/snippet_header.vue'; import SnippetTitle from '~/snippets/components/snippet_title.vue'; import SnippetBlob from '~/snippets/components/snippet_blob_view.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; -import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; +import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; -import { shallowMount } from '@vue/test-utils'; -import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; +import { + SNIPPET_VISIBILITY_INTERNAL, + SNIPPET_VISIBILITY_PRIVATE, + SNIPPET_VISIBILITY_PUBLIC, +} from '~/snippets/constants'; describe('Snippet view app', () => { let wrapper; const defaultProps = { snippetGid: 'gid://gitlab/PersonalSnippet/42', }; + const webUrl = 'http://foo.bar'; + const dummyHTTPUrl = webUrl; + const dummySSHUrl = 'ssh://foo.bar'; function createComponent({ props = defaultProps, data = {}, loading = false } = {}) { const $apollo = { @@ -72,4 +80,47 @@ describe('Snippet view app', () => { expect(blobs.at(0).props('blob')).toEqual(Blob); expect(blobs.at(1).props('blob')).toEqual(BinaryBlob); }); + + describe('Embed dropdown rendering', () => { + it.each` + visibilityLevel | condition | isRendered + ${SNIPPET_VISIBILITY_INTERNAL} | ${'not render'} | ${false} + ${SNIPPET_VISIBILITY_PRIVATE} | ${'not render'} | ${false} + ${'foo'} | ${'not render'} | ${false} + ${SNIPPET_VISIBILITY_PUBLIC} | ${'render'} | ${true} + `('does $condition blob-embeddable by default', ({ visibilityLevel, isRendered }) => { + createComponent({ + data: { + snippet: { + visibilityLevel, + webUrl, + }, + }, + }); + expect(wrapper.contains(BlobEmbeddable)).toBe(isRendered); + }); + }); + + describe('Clone button rendering', () => { + it.each` + httpUrlToRepo | sshUrlToRepo | shouldRender | isRendered + ${null} | ${null} | ${'Should not'} | ${false} + ${null} | ${dummySSHUrl} | ${'Should'} | ${true} + ${dummyHTTPUrl} | ${null} | ${'Should'} | ${true} + ${dummyHTTPUrl} | ${dummySSHUrl} | ${'Should'} | ${true} + `( + '$shouldRender render "Clone" button when `httpUrlToRepo` is $httpUrlToRepo and `sshUrlToRepo` is $sshUrlToRepo', + ({ httpUrlToRepo, sshUrlToRepo, isRendered }) => { + createComponent({ + data: { + snippet: { + sshUrlToRepo, + httpUrlToRepo, + }, + }, + }); + expect(wrapper.contains(CloneDropdownButton)).toBe(isRendered); + }, + ); + }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js new file mode 100644 index 00000000000..8b2051008d7 --- /dev/null +++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js @@ -0,0 +1,301 @@ +import { times } from 'lodash'; +import { shallowMount } from '@vue/test-utils'; +import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; +import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; +import { + SNIPPET_MAX_BLOBS, + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_MOVE, +} from '~/snippets/constants'; +import { testEntries, createBlobFromTestEntry } from '../test_utils'; + +const TEST_BLOBS = [ + createBlobFromTestEntry(testEntries.updated), + createBlobFromTestEntry(testEntries.deleted), +]; + +const TEST_BLOBS_UNLOADED = TEST_BLOBS.map(blob => ({ ...blob, content: '', isLoaded: false })); + +describe('snippets/components/snippet_blob_actions_edit', () => { + let wrapper; + + const createComponent = (props = {}, snippetMultipleFiles = true) => { + wrapper = shallowMount(SnippetBlobActionsEdit, { + propsData: { + initBlobs: TEST_BLOBS, + ...props, + }, + provide: { + glFeatures: { + snippetMultipleFiles, + }, + }, + }); + }; + + const findLabel = () => wrapper.find('label'); + const findBlobEdits = () => wrapper.findAll(SnippetBlobEdit); + const findBlobsData = () => + findBlobEdits().wrappers.map(x => ({ + blob: x.props('blob'), + classes: x.classes(), + })); + const findFirstBlobEdit = () => findBlobEdits().at(0); + const findAddButton = () => wrapper.find('[data-testid="add_button"]'); + const getLastActions = () => { + const events = wrapper.emitted().actions; + + return events[events.length - 1]?.[0]; + }; + const buildBlobsDataExpectation = blobs => + blobs.map((blob, index) => ({ + blob: { + ...blob, + id: expect.stringMatching('blob_local_'), + }, + classes: index > 0 ? ['gl-mt-3'] : [], + })); + const triggerBlobDelete = idx => + findBlobEdits() + .at(idx) + .vm.$emit('delete'); + const triggerBlobUpdate = (idx, props) => + findBlobEdits() + .at(idx) + .vm.$emit('blob-updated', props); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + featureFlag | label | showDelete | showAdd + ${true} | ${'Files'} | ${true} | ${true} + ${false} | ${'File'} | ${false} | ${false} + `('with feature flag = $featureFlag', ({ featureFlag, label, showDelete, showAdd }) => { + beforeEach(() => { + createComponent({}, featureFlag); + }); + + it('renders label', () => { + expect(findLabel().text()).toBe(label); + }); + + it(`renders delete button (show=${showDelete})`, () => { + expect(findFirstBlobEdit().props()).toMatchObject({ + showDelete, + canDelete: true, + }); + }); + + it(`renders add button (show=${showAdd})`, () => { + expect(findAddButton().exists()).toBe(showAdd); + }); + }); + + describe('with default', () => { + beforeEach(() => { + createComponent(); + }); + + it('emits no actions', () => { + expect(getLastActions()).toEqual([]); + }); + + it('shows blobs', () => { + expect(findBlobsData()).toEqual(buildBlobsDataExpectation(TEST_BLOBS_UNLOADED)); + }); + + it('shows add button', () => { + const button = findAddButton(); + + expect(button.text()).toBe(`Add another file ${TEST_BLOBS.length}/${SNIPPET_MAX_BLOBS}`); + expect(button.props('disabled')).toBe(false); + }); + + describe('when add is clicked', () => { + beforeEach(() => { + findAddButton().vm.$emit('click'); + }); + + it('adds blob with empty content', () => { + expect(findBlobsData()).toEqual( + buildBlobsDataExpectation([ + ...TEST_BLOBS_UNLOADED, + { + content: '', + isLoaded: true, + path: '', + }, + ]), + ); + }); + + it('emits action', () => { + expect(getLastActions()).toEqual([ + expect.objectContaining({ + action: SNIPPET_BLOB_ACTION_CREATE, + }), + ]); + }); + }); + + describe('when blob is deleted', () => { + beforeEach(() => { + triggerBlobDelete(1); + }); + + it('removes blob', () => { + expect(findBlobsData()).toEqual(buildBlobsDataExpectation(TEST_BLOBS_UNLOADED.slice(0, 1))); + }); + + it('emits action', () => { + expect(getLastActions()).toEqual([ + expect.objectContaining({ + ...testEntries.deleted.diff, + content: '', + }), + ]); + }); + }); + + describe('when blob changes path', () => { + beforeEach(() => { + triggerBlobUpdate(0, { path: 'new/path' }); + }); + + it('renames blob', () => { + expect(findBlobsData()[0]).toMatchObject({ + blob: { + path: 'new/path', + }, + }); + }); + + it('emits action', () => { + expect(getLastActions()).toMatchObject([ + { + action: SNIPPET_BLOB_ACTION_MOVE, + filePath: 'new/path', + previousPath: testEntries.updated.diff.filePath, + }, + ]); + }); + }); + + describe('when blob emits new content', () => { + const { content } = testEntries.updated.diff; + const originalContent = `${content}\noriginal content\n`; + + beforeEach(() => { + triggerBlobUpdate(0, { content: originalContent }); + }); + + it('loads new content', () => { + expect(findBlobsData()[0]).toMatchObject({ + blob: { + content: originalContent, + isLoaded: true, + }, + }); + }); + + it('does not emit an action', () => { + expect(getLastActions()).toEqual([]); + }); + + it('emits an action when content changes again', async () => { + triggerBlobUpdate(0, { content }); + + await wrapper.vm.$nextTick(); + + expect(getLastActions()).toEqual([testEntries.updated.diff]); + }); + }); + }); + + describe('with 1 blob', () => { + beforeEach(() => { + createComponent({ initBlobs: [createBlobFromTestEntry(testEntries.created)] }); + }); + + it('disables delete button', () => { + expect(findBlobEdits()).toHaveLength(1); + expect( + findBlobEdits() + .at(0) + .props(), + ).toMatchObject({ + showDelete: true, + canDelete: false, + }); + }); + + describe(`when added ${SNIPPET_MAX_BLOBS} files`, () => { + let addButton; + + beforeEach(() => { + addButton = findAddButton(); + + times(SNIPPET_MAX_BLOBS - 1, () => addButton.vm.$emit('click')); + }); + + it('should have blobs', () => { + expect(findBlobsData()).toHaveLength(SNIPPET_MAX_BLOBS); + }); + + it('should disable add button', () => { + expect(addButton.props('disabled')).toBe(true); + }); + }); + }); + + describe('with 0 init blob', () => { + beforeEach(() => { + createComponent({ initBlobs: [] }); + }); + + it('shows 1 blob by default', () => { + expect(findBlobsData()).toEqual([ + expect.objectContaining({ + blob: { + id: expect.stringMatching('blob_local_'), + content: '', + path: '', + isLoaded: true, + }, + }), + ]); + }); + + it('emits create action', () => { + expect(getLastActions()).toEqual([ + { + action: SNIPPET_BLOB_ACTION_CREATE, + content: '', + filePath: '', + previousPath: '', + }, + ]); + }); + }); + + describe(`with ${SNIPPET_MAX_BLOBS} files`, () => { + beforeEach(() => { + const initBlobs = Array(SNIPPET_MAX_BLOBS) + .fill(1) + .map(() => createBlobFromTestEntry(testEntries.created)); + + createComponent({ initBlobs }); + }); + + it('should have blobs', () => { + expect(findBlobsData()).toHaveLength(SNIPPET_MAX_BLOBS); + }); + + it('should disable add button', () => { + expect(findAddButton().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index 009074b4558..188f9ae5cf1 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -1,165 +1,168 @@ -import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; -import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { TEST_HOST } from 'helpers/test_constants'; +import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; +import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; +import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; -import waitForPromises from 'helpers/wait_for_promises'; - -jest.mock('~/blob/utils', () => jest.fn()); - -jest.mock('~/lib/utils/url_utility', () => ({ - getBaseURL: jest.fn().mockReturnValue('foo/'), - joinPaths: jest - .fn() - .mockName('joinPaths') - .mockReturnValue('contentApiURL'), -})); +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); -let flashSpy; +const TEST_ID = 'blob_local_7'; +const TEST_PATH = 'foo/bar/test.md'; +const TEST_RAW_PATH = '/gitlab/raw/path/to/blob/7'; +const TEST_FULL_PATH = joinPaths(TEST_HOST, TEST_RAW_PATH); +const TEST_CONTENT = 'Lorem ipsum dolar sit amet,\nconsectetur adipiscing elit.'; + +const TEST_BLOB = { + id: TEST_ID, + rawPath: TEST_RAW_PATH, + path: TEST_PATH, + content: '', + isLoaded: false, +}; + +const TEST_BLOB_LOADED = { + ...TEST_BLOB, + content: TEST_CONTENT, + isLoaded: true, +}; describe('Snippet Blob Edit component', () => { let wrapper; let axiosMock; - const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; - const pathMock = 'lorem.txt'; - const rawPathMock = 'foo/bar'; - const blob = { - path: pathMock, - content: contentMock, - rawPath: rawPathMock, - }; - const findComponent = component => wrapper.find(component); - function createComponent(props = {}, data = { isContentLoading: false }) { + const createComponent = (props = {}) => { wrapper = shallowMount(SnippetBlobEdit, { propsData: { + blob: TEST_BLOB, ...props, }, - data() { - return { - ...data, - }; - }, }); - flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure'); - } + }; + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findHeader = () => wrapper.find(BlobHeaderEdit); + const findContent = () => wrapper.find(BlobContentEdit); + const getLastUpdatedArgs = () => { + const event = wrapper.emitted()['blob-updated']; + + return event?.[event.length - 1][0]; + }; beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); - createComponent(); + axiosMock.onGet(TEST_FULL_PATH).reply(200, TEST_CONTENT); }); afterEach(() => { - axiosMock.restore(); wrapper.destroy(); + wrapper = null; + axiosMock.restore(); }); - describe('rendering', () => { - it('matches the snapshot', () => { - createComponent({ blob }); - expect(wrapper.element).toMatchSnapshot(); + describe('with not loaded blob', () => { + beforeEach(async () => { + createComponent(); }); - it('renders required components', () => { - expect(findComponent(BlobHeaderEdit).exists()).toBe(true); - expect(findComponent(BlobContentEdit).exists()).toBe(true); + it('shows blob header', () => { + expect(findHeader().props()).toMatchObject({ + value: TEST_BLOB.path, + }); + expect(findHeader().attributes('id')).toBe(`${TEST_ID}_file_path`); }); - it('renders loader if existing blob is supplied but no content is fetched yet', () => { - createComponent({ blob }, { isContentLoading: true }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); - expect(findComponent(BlobContentEdit).exists()).toBe(false); + it('emits delete when deleted', () => { + expect(wrapper.emitted().delete).toBeUndefined(); + + findHeader().vm.$emit('delete'); + + expect(wrapper.emitted().delete).toHaveLength(1); }); - it('does not render loader if when blob is not supplied', () => { - createComponent(); - expect(wrapper.contains(GlLoadingIcon)).toBe(false); - expect(findComponent(BlobContentEdit).exists()).toBe(true); + it('emits update when path changes', () => { + const newPath = 'new/path.md'; + + findHeader().vm.$emit('input', newPath); + + expect(getLastUpdatedArgs()).toEqual({ path: newPath }); }); - }); - describe('functionality', () => { - it('does not fail without blob', () => { - const spy = jest.spyOn(global.console, 'error'); - createComponent({ blob: undefined }); + it('emits update when content is loaded', async () => { + await waitForPromises(); - expect(spy).not.toHaveBeenCalled(); - expect(findComponent(BlobContentEdit).exists()).toBe(true); + expect(getLastUpdatedArgs()).toEqual({ content: TEST_CONTENT }); }); + }); - it.each` - emitter | prop - ${BlobHeaderEdit} | ${'filePath'} - ${BlobContentEdit} | ${'content'} - `('emits "blob-updated" event when the $prop gets changed', ({ emitter, prop }) => { - expect(wrapper.emitted('blob-updated')).toBeUndefined(); - const newValue = 'foo.bar'; - findComponent(emitter).vm.$emit('input', newValue); - - return nextTick().then(() => { - expect(wrapper.emitted('blob-updated')[0]).toEqual([ - expect.objectContaining({ - [prop]: newValue, - }), - ]); - }); + describe('with error', () => { + beforeEach(() => { + axiosMock.reset(); + axiosMock.onGet(TEST_FULL_PATH).replyOnce(500); + createComponent(); }); - describe('fetching blob content', () => { - const bootstrapForExistingSnippet = resp => { - createComponent({ - blob: { - ...blob, - content: '', - }, - }); + it('should call flash', async () => { + await waitForPromises(); - if (resp === 500) { - axiosMock.onGet('contentApiURL').reply(500); - } else { - axiosMock.onGet('contentApiURL').reply(200, contentMock); - } - }; + expect(createFlash).toHaveBeenCalledWith( + "Can't fetch content for the blob: Error: Request failed with status code 500", + ); + }); + }); - const bootstrapForNewSnippet = () => { - createComponent(); - }; + describe('with loaded blob', () => { + beforeEach(() => { + createComponent({ blob: TEST_BLOB_LOADED }); + }); - it('fetches blob content with the additional query', () => { - bootstrapForExistingSnippet(); + it('matches snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - return waitForPromises().then(() => { - expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock); - expect(findComponent(BlobHeaderEdit).props('value')).toBe(pathMock); - expect(findComponent(BlobContentEdit).props('value')).toBe(contentMock); - }); - }); + it('does not make API request', () => { + expect(axiosMock.history.get).toHaveLength(0); + }); + }); - it('flashes the error message if fetching content fails', () => { - bootstrapForExistingSnippet(500); + describe.each` + props | showLoading | showContent + ${{ blob: TEST_BLOB, canDelete: true, showDelete: true }} | ${true} | ${false} + ${{ blob: TEST_BLOB, canDelete: false, showDelete: false }} | ${true} | ${false} + ${{ blob: TEST_BLOB_LOADED }} | ${false} | ${true} + `('with $props', ({ props, showLoading, showContent }) => { + beforeEach(() => { + createComponent(props); + }); - return waitForPromises().then(() => { - expect(flashSpy).toHaveBeenCalled(); - expect(findComponent(BlobContentEdit).props('value')).toBe(''); - }); + it('shows blob header', () => { + const { canDelete = true, showDelete = false } = props; + + expect(findHeader().props()).toMatchObject({ + canDelete, + showDelete, }); + }); - it('does not fetch content for new snippet', () => { - bootstrapForNewSnippet(); + it(`handles loading icon (show=${showLoading})`, () => { + expect(findLoadingIcon().exists()).toBe(showLoading); + }); - return waitForPromises().then(() => { - // we keep using waitForPromises to make sure we do not run failed test - expect(findComponent(BlobHeaderEdit).props('value')).toBe(''); - expect(findComponent(BlobContentEdit).props('value')).toBe(''); - expect(joinPaths).not.toHaveBeenCalled(); + it(`handles content (show=${showContent})`, () => { + expect(findContent().exists()).toBe(showContent); + + if (showContent) { + expect(findContent().props()).toEqual({ + value: TEST_BLOB_LOADED.content, + fileGlobalId: TEST_BLOB_LOADED.id, + fileName: TEST_BLOB_LOADED.path, }); - }); + } }); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index c8f1c8fc8a9..9c4b2734a3f 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -1,7 +1,14 @@ +import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; +import { + Blob as BlobMock, + SimpleViewerMock, + RichViewerMock, + RichBlobContentMock, + SimpleBlobContentMock, +} from 'jest/blob/components/mock_data'; import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import BlobContent from '~/blob/components/blob_content.vue'; import { BLOB_RENDER_EVENT_LOAD, @@ -9,13 +16,7 @@ import { BLOB_RENDER_ERRORS, } from '~/blob/components/constants'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; -import { - SNIPPET_VISIBILITY_PRIVATE, - SNIPPET_VISIBILITY_INTERNAL, - SNIPPET_VISIBILITY_PUBLIC, -} from '~/snippets/constants'; - -import { Blob as BlobMock, SimpleViewerMock, RichViewerMock } from 'jest/blob/components/mock_data'; +import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; describe('Blob Embeddable', () => { let wrapper; @@ -72,18 +73,6 @@ describe('Blob Embeddable', () => { expect(wrapper.find(BlobContent).exists()).toBe(true); }); - it.each([SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PRIVATE, 'foo'])( - 'does not render blob-embeddable by default', - visibilityLevel => { - createComponent({ - snippetProps: { - visibilityLevel, - }, - }); - expect(wrapper.find(BlobEmbeddable).exists()).toBe(false); - }, - ); - it('sets simple viewer correctly', () => { createComponent(); expect(wrapper.find(SimpleViewer).exists()).toBe(true); @@ -128,6 +117,59 @@ describe('Blob Embeddable', () => { expect(wrapper.find(BlobHeader).props('hasRenderError')).toBe(true); }); + describe('bob content in multi-file scenario', () => { + const SimpleBlobContentMock2 = { + ...SimpleBlobContentMock, + plainData: 'Another Plain Foo', + }; + const RichBlobContentMock2 = { + ...SimpleBlobContentMock, + richData: 'Another Rich Foo', + }; + + it.each` + snippetBlobs | description | currentBlob | expectedContent + ${[SimpleBlobContentMock]} | ${'one existing textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} + ${[RichBlobContentMock]} | ${'one existing rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} + ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} + ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} + ${[SimpleBlobContentMock, SimpleBlobContentMock2]} | ${'textual blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} + ${[RichBlobContentMock, RichBlobContentMock2]} | ${'rich blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} + `( + 'renders correct content for $description', + async ({ snippetBlobs, currentBlob, expectedContent }) => { + const apolloData = { + snippets: { + edges: [ + { + node: { + blobs: snippetBlobs, + }, + }, + ], + }, + }; + createComponent({ + blob: { + ...BlobMock, + path: currentBlob.path, + }, + }); + + // mimic apollo's update + wrapper.setData({ + blobContent: wrapper.vm.onContentUpdate(apolloData), + }); + + await nextTick(); + + const findContent = () => wrapper.find(BlobContent); + + expect(findContent().props('content')).toBe(expectedContent); + }, + ); + }); + describe('URLS with hash', () => { beforeEach(() => { window.location.hash = '#LC2'; diff --git a/spec/frontend/snippets/components/snippet_description_edit_spec.js b/spec/frontend/snippets/components/snippet_description_edit_spec.js index 816ab4e48de..ff75515e71a 100644 --- a/spec/frontend/snippets/components/snippet_description_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_description_edit_spec.js @@ -1,6 +1,6 @@ +import { shallowMount } from '@vue/test-utils'; import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { shallowMount } from '@vue/test-utils'; describe('Snippet Description Edit component', () => { let wrapper; diff --git a/spec/frontend/snippets/components/snippet_description_view_spec.js b/spec/frontend/snippets/components/snippet_description_view_spec.js index 46467ef311e..14f116f2aaf 100644 --- a/spec/frontend/snippets/components/snippet_description_view_spec.js +++ b/spec/frontend/snippets/components/snippet_description_view_spec.js @@ -1,5 +1,5 @@ -import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; import { shallowMount } from '@vue/test-utils'; +import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; describe('Snippet Description component', () => { let wrapper; diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 0825da92118..da8cb2e6a8d 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -1,46 +1,19 @@ -import SnippetHeader from '~/snippets/components/snippet_header.vue'; -import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; import { ApolloMutation } from 'vue-apollo'; import { GlButton, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; +import waitForPromises from 'helpers/wait_for_promises'; +import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; +import SnippetHeader from '~/snippets/components/snippet_header.vue'; describe('Snippet header component', () => { let wrapper; - const snippet = { - id: 'gid://gitlab/PersonalSnippet/50', - title: 'The property of Thor', - visibilityLevel: 'private', - webUrl: 'http://personal.dev.null/42', - userPermissions: { - adminSnippet: true, - updateSnippet: true, - reportSnippet: false, - }, - project: null, - author: { - name: 'Thor Odinson', - }, - blobs: [Blob], - }; - const mutationVariables = { - mutation: DeleteSnippetMutation, - variables: { - id: snippet.id, - }, - }; - const errorMsg = 'Foo bar'; - const err = { message: errorMsg }; - - const resolveMutate = jest.fn(() => - Promise.resolve({ data: { destroySnippet: { errors: [] } } }), - ); - const rejectMutation = jest.fn(() => Promise.reject(err)); - - const mutationTypes = { - RESOLVE: resolveMutate, - REJECT: rejectMutation, - }; + let snippet; + let mutationTypes; + let mutationVariables; + + let errorMsg; + let err; function createComponent({ loading = false, @@ -63,7 +36,7 @@ describe('Snippet header component', () => { mutate: mutationRes, }; - wrapper = shallowMount(SnippetHeader, { + wrapper = mount(SnippetHeader, { mocks: { $apollo }, propsData: { snippet: { @@ -76,6 +49,41 @@ describe('Snippet header component', () => { }); } + beforeEach(() => { + snippet = { + id: 'gid://gitlab/PersonalSnippet/50', + title: 'The property of Thor', + visibilityLevel: 'private', + webUrl: 'http://personal.dev.null/42', + userPermissions: { + adminSnippet: true, + updateSnippet: true, + reportSnippet: false, + }, + project: null, + author: { + name: 'Thor Odinson', + }, + blobs: [Blob], + createdAt: new Date(Date.now() - 32 * 24 * 3600 * 1000).toISOString(), + }; + + mutationVariables = { + mutation: DeleteSnippetMutation, + variables: { + id: snippet.id, + }, + }; + + errorMsg = 'Foo bar'; + err = { message: errorMsg }; + + mutationTypes = { + RESOLVE: jest.fn(() => Promise.resolve({ data: { destroySnippet: { errors: [] } } })), + REJECT: jest.fn(() => Promise.reject(err)), + }; + }); + afterEach(() => { wrapper.destroy(); }); @@ -85,6 +93,23 @@ describe('Snippet header component', () => { expect(wrapper.find('.detail-page-header').exists()).toBe(true); }); + it('renders a message showing snippet creation date and author', () => { + createComponent(); + + const text = wrapper.find('[data-testid="authored-message"]').text(); + expect(text).toContain('Authored 1 month ago by'); + expect(text).toContain('Thor Odinson'); + }); + + it('renders a message showing only snippet creation date if author is null', () => { + snippet.author = null; + + createComponent(); + + const text = wrapper.find('[data-testid="authored-message"]').text(); + expect(text).toBe('Authored 1 month ago'); + }); + it('renders action buttons based on permissions', () => { createComponent({ permissions: { @@ -163,14 +188,15 @@ describe('Snippet header component', () => { expect(mutationTypes.RESOLVE).toHaveBeenCalledWith(mutationVariables); }); - it('sets error message if mutation fails', () => { + it('sets error message if mutation fails', async () => { createComponent({ mutationRes: mutationTypes.REJECT }); expect(Boolean(wrapper.vm.errorMessage)).toBe(false); wrapper.vm.deleteSnippet(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.errorMessage).toEqual(errorMsg); - }); + + await waitForPromises(); + + expect(wrapper.vm.errorMessage).toEqual(errorMsg); }); describe('in case of successful mutation, closes modal and redirects to correct listing', () => { @@ -199,7 +225,7 @@ describe('Snippet header component', () => { }, }).then(() => { expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled(); - expect(window.location.pathname).toBe(`${fullPath}/snippets`); + expect(window.location.pathname).toBe(`${fullPath}/-/snippets`); }); }); }); diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js index 88261a75f6c..f201cfb19b7 100644 --- a/spec/frontend/snippets/components/snippet_title_spec.js +++ b/spec/frontend/snippets/components/snippet_title_spec.js @@ -1,7 +1,7 @@ -import SnippetTitle from '~/snippets/components/snippet_title.vue'; -import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import SnippetTitle from '~/snippets/components/snippet_title.vue'; +import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; describe('Snippet header component', () => { let wrapper; diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js index 0bdef71bc08..a8df13787a5 100644 --- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js @@ -1,12 +1,12 @@ -import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; import { GlFormRadio, GlIcon, GlFormRadioGroup, GlLink } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; import { SNIPPET_VISIBILITY, SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC, } from '~/snippets/constants'; -import { mount, shallowMount } from '@vue/test-utils'; describe('Snippet Visibility Edit component', () => { let wrapper; diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js new file mode 100644 index 00000000000..86262723157 --- /dev/null +++ b/spec/frontend/snippets/test_utils.js @@ -0,0 +1,76 @@ +import { + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_UPDATE, + SNIPPET_BLOB_ACTION_MOVE, + SNIPPET_BLOB_ACTION_DELETE, +} from '~/snippets/constants'; + +const CONTENT_1 = 'Lorem ipsum dolar\nSit amit\n\nGoodbye!\n'; +const CONTENT_2 = 'Lorem ipsum dolar sit amit.\n\nGoodbye!\n'; + +export const testEntries = { + created: { + id: 'blob_1', + diff: { + action: SNIPPET_BLOB_ACTION_CREATE, + filePath: '/new/file', + previousPath: '/new/file', + content: CONTENT_1, + }, + }, + deleted: { + id: 'blob_2', + diff: { + action: SNIPPET_BLOB_ACTION_DELETE, + filePath: '/src/delete/me', + previousPath: '/src/delete/me', + content: CONTENT_1, + }, + }, + updated: { + id: 'blob_3', + origContent: CONTENT_1, + diff: { + action: SNIPPET_BLOB_ACTION_UPDATE, + filePath: '/lorem.md', + previousPath: '/lorem.md', + content: CONTENT_2, + }, + }, + renamed: { + id: 'blob_4', + diff: { + action: SNIPPET_BLOB_ACTION_MOVE, + filePath: '/dolar.md', + previousPath: '/ipsum.md', + content: CONTENT_1, + }, + }, + renamedAndUpdated: { + id: 'blob_5', + origContent: CONTENT_1, + diff: { + action: SNIPPET_BLOB_ACTION_MOVE, + filePath: '/sit.md', + previousPath: '/sit/amit.md', + content: CONTENT_2, + }, + }, +}; + +export const createBlobFromTestEntry = ({ diff, origContent }, isOrig = false) => ({ + content: isOrig && origContent ? origContent : diff.content, + path: isOrig ? diff.previousPath : diff.filePath, +}); + +export const createBlobsFromTestEntries = (entries, isOrig = false) => + entries.reduce( + (acc, entry) => + Object.assign(acc, { + [entry.id]: { + id: entry.id, + ...createBlobFromTestEntry(entry, isOrig), + }, + }), + {}, + ); diff --git a/spec/frontend/snippets/utils/blob_spec.js b/spec/frontend/snippets/utils/blob_spec.js new file mode 100644 index 00000000000..c20cf2e6102 --- /dev/null +++ b/spec/frontend/snippets/utils/blob_spec.js @@ -0,0 +1,63 @@ +import { cloneDeep } from 'lodash'; +import { decorateBlob, createBlob, diffAll } from '~/snippets/utils/blob'; +import { testEntries, createBlobsFromTestEntries } from '../test_utils'; + +jest.mock('lodash/uniqueId', () => arg => `${arg}fakeUniqueId`); + +const TEST_RAW_BLOB = { + rawPath: '/test/blob/7/raw', +}; + +describe('~/snippets/utils/blob', () => { + describe('decorateBlob', () => { + it('should decorate the given object with local blob properties', () => { + const orig = cloneDeep(TEST_RAW_BLOB); + + expect(decorateBlob(orig)).toEqual({ + ...TEST_RAW_BLOB, + id: 'blob_local_fakeUniqueId', + isLoaded: false, + content: '', + }); + }); + }); + + describe('createBlob', () => { + it('should create an empty local blob', () => { + expect(createBlob()).toEqual({ + id: 'blob_local_fakeUniqueId', + isLoaded: true, + content: '', + path: '', + }); + }); + }); + + describe('diffAll', () => { + it('should create diff from original files', () => { + const origBlobs = createBlobsFromTestEntries( + [ + testEntries.deleted, + testEntries.updated, + testEntries.renamed, + testEntries.renamedAndUpdated, + ], + true, + ); + const blobs = createBlobsFromTestEntries([ + testEntries.created, + testEntries.updated, + testEntries.renamed, + testEntries.renamedAndUpdated, + ]); + + expect(diffAll(blobs, origBlobs)).toEqual([ + testEntries.deleted.diff, + testEntries.created.diff, + testEntries.updated.diff, + testEntries.renamed.diff, + testEntries.renamedAndUpdated.diff, + ]); + }); + }); +}); |