Commit 02de2063 authored by Savas Vedova's avatar Savas Vedova Committed by Mark Florian

Fetch discussions using GraphQL

This commit fetches the vulnerability history discussions using
GraphQL instead of using the REST endpoint. Also, while fetching
the discussions and notes, we now display a loading spinner.

Changelog: changed
parent 9a5456ab
query vulnerabilityDiscussions(
$id: VulnerabilityID!
$after: String
$before: String
$first: Int
$last: Int
) {
vulnerability(id: $id) {
id
discussions(after: $after, before: $before, first: $first, last: $last) {
nodes {
id
replyId
}
}
}
}
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import Api from 'ee/api'; import Api from 'ee/api';
import vulnerabilityDiscussionsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_discussions.query.graphql';
import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue'; import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue'; import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants'; import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
...@@ -27,6 +30,7 @@ export default { ...@@ -27,6 +30,7 @@ export default {
HistoryEntry, HistoryEntry,
RelatedIssues, RelatedIssues,
RelatedJiraIssues, RelatedJiraIssues,
GlLoadingIcon,
GlIcon, GlIcon,
StatusDescription, StatusDescription,
}, },
...@@ -44,14 +48,53 @@ export default { ...@@ -44,14 +48,53 @@ export default {
}, },
data() { data() {
return { return {
discussionsDictionary: {}, notesLoading: true,
discussions: [],
lastFetchedAt: null, lastFetchedAt: null,
}; };
}, },
computed: { apollo: {
discussions() { discussions: {
return Object.values(this.discussionsDictionary); query: vulnerabilityDiscussionsQuery,
variables() {
return { id: convertToGraphQLId(TYPE_VULNERABILITY, this.vulnerability.id) };
},
update: ({ vulnerability }) => {
if (!vulnerability) {
return [];
}
return vulnerability.discussions.nodes.map((d) => ({ ...d, notes: [] }));
},
result({ error }) {
if (!this.poll && !error) {
this.createNotesPoll();
if (!Visibility.hidden()) {
this.poll.makeRequest();
}
Visibility.change(() => {
if (Visibility.hidden()) {
this.poll.stop();
} else {
this.poll.restart();
}
});
}
},
error() {
this.notesLoading = false;
createFlash({
message: s__(
'VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later.',
),
});
},
}, },
},
computed: {
noteDictionary() { noteDictionary() {
return this.discussions return this.discussions
.flatMap((x) => x.notes) .flatMap((x) => x.notes)
...@@ -94,56 +137,19 @@ export default { ...@@ -94,56 +137,19 @@ export default {
}; };
}, },
}, },
created() {
this.fetchDiscussions();
},
updated() { updated() {
this.$nextTick(() => { this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link')); initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
}); });
}, },
beforeDestroy() { beforeDestroy() {
if (this.poll) this.poll.stop(); if (this.poll) {
this.poll.stop();
}
}, },
methods: { methods: {
dateToSeconds(date) { findDiscussion(id) {
return Date.parse(date) / 1000; return this.discussions.find((d) => d.id === id);
},
fetchDiscussions() {
// note: this direct API call will be replaced when migrating the vulnerability details page to GraphQL
// related epic: https://gitlab.com/groups/gitlab-org/-/epics/3657
axios
.get(this.vulnerability.discussionsUrl)
.then(({ data, headers: { date } }) => {
this.discussionsDictionary = data.reduce((acc, discussion) => {
acc[discussion.id] = convertObjectPropsToCamelCase(discussion, { deep: true });
return acc;
}, {});
this.lastFetchedAt = this.dateToSeconds(date);
if (!this.poll) this.createNotesPoll();
if (!Visibility.hidden()) {
// delays the initial request by 6 seconds
this.poll.makeDelayedRequest(6 * 1000);
}
Visibility.change(() => {
if (Visibility.hidden()) {
this.poll.stop();
} else {
this.poll.restart();
}
});
})
.catch(() => {
createFlash({
message: s__(
'VulnerabilityManagement|Something went wrong while trying to retrieve the vulnerability history. Please try again later.',
),
});
});
}, },
createNotesPoll() { createNotesPoll() {
// note: this polling call will be replaced when migrating the vulnerability details page to GraphQL // note: this polling call will be replaced when migrating the vulnerability details page to GraphQL
...@@ -159,48 +165,46 @@ export default { ...@@ -159,48 +165,46 @@ export default {
successCallback: ({ data: { notes, last_fetched_at: lastFetchedAt } }) => { successCallback: ({ data: { notes, last_fetched_at: lastFetchedAt } }) => {
this.updateNotes(convertObjectPropsToCamelCase(notes, { deep: true })); this.updateNotes(convertObjectPropsToCamelCase(notes, { deep: true }));
this.lastFetchedAt = lastFetchedAt; this.lastFetchedAt = lastFetchedAt;
this.notesLoading = false;
}, },
errorCallback: () => errorCallback: () => {
this.notesLoading = false;
createFlash({ createFlash({
message: __('Something went wrong while fetching latest comments.'), message: __('Something went wrong while fetching latest comments.'),
}), });
},
}); });
}, },
updateNotes(notes) { updateNotes(notes) {
let isVulnerabilityStateChanged = false; let shallEmitVulnerabilityChangedEvent;
notes.forEach((note) => { notes.forEach((note) => {
const discussion = this.findDiscussion(note.discussionId);
// If the note exists, update it. // If the note exists, update it.
if (this.noteDictionary[note.id]) { if (this.noteDictionary[note.id]) {
const updatedDiscussion = { ...this.discussionsDictionary[note.discussionId] }; discussion.notes = discussion.notes.map((curr) => (curr.id === note.id ? note : curr));
updatedDiscussion.notes = updatedDiscussion.notes.map((curr) =>
curr.id === note.id ? note : curr,
);
this.discussionsDictionary[note.discussionId] = updatedDiscussion;
} }
// If the note doesn't exist, but the discussion does, add the note to the discussion. // If the note doesn't exist, but the discussion does, add the note to the discussion.
else if (this.discussionsDictionary[note.discussionId]) { else if (discussion) {
const updatedDiscussion = { ...this.discussionsDictionary[note.discussionId] }; discussion.notes.push(note);
updatedDiscussion.notes.push(note);
this.discussionsDictionary[note.discussionId] = updatedDiscussion;
} }
// If the discussion doesn't exist, create it. // If the discussion doesn't exist, create it.
else { else {
const newDiscussion = { this.discussions.push({
id: note.discussionId, id: note.discussionId,
replyId: note.discussionId, replyId: note.discussionId,
notes: [note], notes: [note],
}; });
this.$set(this.discussionsDictionary, newDiscussion.id, newDiscussion);
// If the vulnerability status has changed, the note will be a system note. // If the vulnerability status has changed, the note will be a system note.
// Emit an event that tells the header to refresh the vulnerability.
if (note.system === true) { if (note.system === true) {
isVulnerabilityStateChanged = true; shallEmitVulnerabilityChangedEvent = true;
} }
} }
}); });
// Emit an event that tells the header to refresh the vulnerability.
if (isVulnerabilityStateChanged) { if (shallEmitVulnerabilityChangedEvent) {
this.$emit('vulnerability-state-change'); this.$emit('vulnerability-state-change');
} }
}, },
...@@ -243,7 +247,8 @@ export default { ...@@ -243,7 +247,8 @@ export default {
</div> </div>
</div> </div>
<hr /> <hr />
<ul v-if="discussions.length" ref="historyList" class="notes discussion-body"> <gl-loading-icon v-if="notesLoading" />
<ul v-else-if="discussions.length" class="notes discussion-body">
<history-entry <history-entry
v-for="discussion in discussions" v-for="discussion in discussions"
:key="discussion.id" :key="discussion.id"
......
import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Api from 'ee/api'; import Api from 'ee/api';
import vulnerabilityDiscussionsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_discussions.query.graphql';
import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue'; import MergeRequestNote from 'ee/vue_shared/security_reports/components/merge_request_note.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue'; import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue'; import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
...@@ -10,20 +13,25 @@ import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue'; ...@@ -10,20 +13,25 @@ import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import RelatedJiraIssues from 'ee/vulnerabilities/components/related_jira_issues.vue'; import RelatedJiraIssues from 'ee/vulnerabilities/components/related_jira_issues.vue';
import StatusDescription from 'ee/vulnerabilities/components/status_description.vue'; import StatusDescription from 'ee/vulnerabilities/components/status_description.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants'; import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
const mockAxios = new MockAdapter(axios); const mockAxios = new MockAdapter(axios);
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/user_popovers'); jest.mock('~/user_popovers');
Vue.use(VueApollo);
describe('Vulnerability Footer', () => { describe('Vulnerability Footer', () => {
let wrapper; let wrapper;
const vulnerability = { const vulnerability = {
id: 1, id: 1,
discussionsUrl: '/discussions',
notesUrl: '/notes', notesUrl: '/notes',
project: { project: {
fullPath: '/root/security-reports', fullPath: '/root/security-reports',
...@@ -35,244 +43,242 @@ describe('Vulnerability Footer', () => { ...@@ -35,244 +43,242 @@ describe('Vulnerability Footer', () => {
pipeline: {}, pipeline: {},
}; };
const createWrapper = (properties = {}, mountOptions = {}) => { let discussion1;
wrapper = shallowMount(VulnerabilityFooter, { let discussion2;
let notes;
const discussionsSuccessHandler = (nodes) =>
jest.fn().mockResolvedValue({
data: {
vulnerability: {
id: `gid://gitlab/Vulnerability/${vulnerability.id}`,
discussions: {
nodes,
},
},
},
});
const discussionsErrorHandler = () =>
jest.fn().mockRejectedValue({
errors: [{ message: 'Something went wrong' }],
});
const createNotesRequest = (notesArray, statusCode = 200) => {
return mockAxios
.onGet(vulnerability.notesUrl)
.replyOnce(statusCode, { notes: notesArray }, { date: Date.now() });
};
const createWrapper = ({ properties, discussionsHandler, mountOptions } = {}) => {
createNotesRequest(notes);
wrapper = shallowMountExtended(VulnerabilityFooter, {
propsData: { vulnerability: { ...vulnerability, ...properties } }, propsData: { vulnerability: { ...vulnerability, ...properties } },
apolloProvider: createMockApollo([[vulnerabilityDiscussionsQuery, discussionsHandler]]),
...mountOptions, ...mountOptions,
}); });
}; };
const createWrapperWithDiscussions = (props) => {
createWrapper({
...props,
discussionsHandler: discussionsSuccessHandler([discussion1, discussion2]),
});
};
const findDiscussions = () => wrapper.findAllComponents(HistoryEntry);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findMergeRequestNote = () => wrapper.findComponent(MergeRequestNote);
const findRelatedIssues = () => wrapper.findComponent(RelatedIssues);
const findRelatedJiraIssues = () => wrapper.findComponent(RelatedJiraIssues);
beforeEach(() => {
discussion1 = {
id: 'gid://gitlab/Discussion/7b4aa2d000ec81ba374a29b3ca3ee4c5f274f9ab',
replyId: 'gid://gitlab/Discussion/7b4aa2d000ec81ba374a29b3ca3ee4c5f274f9ab',
};
discussion2 = {
id: 'gid://gitlab/Discussion/0656f86109dc755c99c288c54d154b9705aaa796',
replyId: 'gid://gitlab/Discussion/0656f86109dc755c99c288c54d154b9705aaa796',
};
notes = [
{ id: 100, note: 'some note', discussion_id: discussion1.id },
{ id: 200, note: 'another note', discussion_id: discussion2.id },
];
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
mockAxios.reset(); mockAxios.reset();
}); });
describe('fetching discussions', () => { describe('discussions and notes', () => {
it('calls the discussion url on if fetchDiscussions is called by the root', async () => { const createWrapperAndFetchNotes = async () => {
createWrapper(); createWrapperWithDiscussions();
jest.spyOn(axios, 'get'); await axios.waitForAll();
wrapper.vm.fetchDiscussions(); expect(findDiscussions()).toHaveLength(2);
expect(findDiscussions().at(0).props('discussion').notes).toHaveLength(1);
};
const makePollRequest = async () => {
wrapper.vm.poll.makeRequest();
await axios.waitForAll(); await axios.waitForAll();
};
expect(axios.get).toHaveBeenCalledTimes(1); it('displays a loading spinner while fetching discussions', async () => {
createWrapperWithDiscussions();
expect(findDiscussions().exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(true);
await axios.waitForAll();
expect(findLoadingIcon().exists()).toBe(false);
}); });
});
describe('solution card', () => { it('fetches discussions and notes on mount', async () => {
it('does show solution card when there is one', () => { await createWrapperAndFetchNotes();
const properties = { remediations: [{ diff: [{}] }], solution: 'some solution' };
createWrapper(properties);
expect(wrapper.find(SolutionCard).exists()).toBe(true); expect(findDiscussions().at(0).props()).toEqual({
expect(wrapper.find(SolutionCard).props()).toEqual({ discussion: { ...discussion1, notes: [convertObjectPropsToCamelCase(notes[0])] },
solution: properties.solution, notesUrl: vulnerability.notesUrl,
remediation: properties.remediations[0],
hasDownload: true,
hasMr: vulnerability.hasMr,
}); });
});
it('does not show solution card when there is not one', () => {
createWrapper();
expect(wrapper.find(SolutionCard).exists()).toBe(false);
});
});
describe('merge request note', () => { expect(findDiscussions().at(1).props()).toEqual({
const mergeRequestNote = () => wrapper.find(MergeRequestNote); discussion: { ...discussion2, notes: [convertObjectPropsToCamelCase(notes[1])] },
notesUrl: vulnerability.notesUrl,
it('does not show merge request note when a merge request does not exist for the vulnerability', () => { });
createWrapper();
expect(mergeRequestNote().exists()).toBe(false);
});
it('shows merge request note when a merge request exists for the vulnerability', () => {
// The object itself does not matter, we just want to make sure it's passed to the issue note.
const mergeRequestFeedback = {};
createWrapper({ mergeRequestFeedback });
expect(mergeRequestNote().exists()).toBe(true);
expect(mergeRequestNote().props('feedback')).toBe(mergeRequestFeedback);
}); });
});
describe('state history', () => {
const discussionUrl = vulnerability.discussionsUrl;
const historyList = () => wrapper.find({ ref: 'historyList' });
const historyEntries = () => wrapper.findAll(HistoryEntry);
it('does not render the history list if there are no history items', () => { it('calls initUserPopovers when the component is updated', async () => {
mockAxios.onGet(discussionUrl).replyOnce(200, []); createWrapperWithDiscussions();
createWrapper(); expect(initUserPopovers).not.toHaveBeenCalled();
expect(historyList().exists()).toBe(false); await axios.waitForAll();
expect(initUserPopovers).toHaveBeenCalled();
}); });
it('renders the history list if there are history items', () => { it('shows an error the discussions could not be retrieved', async () => {
// The shape of this object doesn't matter for this test, we just need to verify that it's passed to the history createWrapper({ discussionsHandler: discussionsErrorHandler() });
// entry. await waitForPromises();
const historyItems = [ expect(createFlash).toHaveBeenCalledWith({
{ id: 1, note: 'some note' }, message:
{ id: 2, note: 'another note' }, 'Something went wrong while trying to retrieve the vulnerability history. Please try again later.',
];
mockAxios.onGet(discussionUrl).replyOnce(200, historyItems, { date: Date.now() });
createWrapper();
return axios.waitForAll().then(() => {
expect(historyList().exists()).toBe(true);
expect(historyEntries()).toHaveLength(2);
const entry1 = historyEntries().at(0);
const entry2 = historyEntries().at(1);
expect(entry1.props('discussion')).toEqual(historyItems[0]);
expect(entry2.props('discussion')).toEqual(historyItems[1]);
}); });
}); });
it('calls initUserPopovers when a new history item is retrieved', () => { it('adds a new note to an existing discussion if the note does not exist', async () => {
const historyItems = [{ id: 1, note: 'some note' }]; await createWrapperAndFetchNotes();
mockAxios.onGet(discussionUrl).replyOnce(200, historyItems, { date: Date.now() });
expect(initUserPopovers).not.toHaveBeenCalled(); // Fetch a new note
createWrapper(); const note = { id: 101, note: 'new note', discussion_id: discussion1.id };
createNotesRequest([note]);
await makePollRequest();
return axios.waitForAll().then(() => { expect(findDiscussions()).toHaveLength(2);
expect(initUserPopovers).toHaveBeenCalled(); expect(findDiscussions().at(0).props('discussion').notes[1].note).toBe(note.note);
});
}); });
it('shows an error the history list could not be retrieved', () => { it('updates an existing note if it already exists', async () => {
mockAxios.onGet(discussionUrl).replyOnce(500); await createWrapperAndFetchNotes();
createWrapper();
return axios.waitForAll().then(() => { const note = { ...notes[0], note: 'updated note' };
expect(createFlash).toHaveBeenCalledTimes(1); createNotesRequest([note]);
}); await makePollRequest();
});
describe('new notes polling', () => {
jest.useFakeTimers();
const getDiscussion = (entries, index) => entries.at(index).props('discussion'); expect(findDiscussions()).toHaveLength(2);
const createNotesRequest = (...notes) => expect(findDiscussions().at(0).props('discussion').notes).toHaveLength(1);
mockAxios expect(findDiscussions().at(0).props('discussion').notes[0].note).toBe(note.note);
.onGet(vulnerability.notes_url) });
.replyOnce(200, { notes, lastFetchedAt: Date.now() });
// Following #217184 the vulnerability polling uses an initial timeout it('creates a new discussion with a new note if the discussion does not exist', async () => {
// which we need to run and then wait for the subsequent request. await createWrapperAndFetchNotes();
const startTimeoutsAndAwaitRequests = async () => {
expect(setTimeout).toHaveBeenCalledTimes(1);
jest.runAllTimers();
return axios.waitForAll(); const note = {
id: 300,
note: 'new note on a new discussion',
discussion_id: 'new-discussion-id',
}; };
beforeEach(() => { createNotesRequest([note]);
const historyItems = [ await makePollRequest();
{ id: 1, notes: [{ id: 100, note: 'some note', discussion_id: 1 }] },
{ id: 2, notes: [{ id: 200, note: 'another note', discussion_id: 2 }] },
];
mockAxios.onGet(discussionUrl).replyOnce(200, historyItems, { date: Date.now() });
createWrapper();
});
it('updates an existing note if it already exists', () => {
const note = { id: 100, note: 'updated note', discussion_id: 1 };
createNotesRequest(note);
return axios.waitForAll().then(async () => { expect(findDiscussions()).toHaveLength(3);
await startTimeoutsAndAwaitRequests(); expect(findDiscussions().at(2).props('discussion').notes).toHaveLength(1);
expect(findDiscussions().at(2).props('discussion').notes[0].note).toBe(note.note);
const entries = historyEntries(); });
expect(entries).toHaveLength(2);
const discussion = getDiscussion(entries, 0);
expect(discussion.notes.length).toBe(1);
expect(discussion.notes[0].note).toBe('updated note');
});
});
it('adds a new note to an existing discussion if the note does not exist', () => { it('shows an error if the notes poll fails', async () => {
const note = { id: 101, note: 'new note', discussion_id: 1 }; await createWrapperAndFetchNotes();
createNotesRequest(note);
return axios.waitForAll().then(async () => { createNotesRequest([], 500);
await startTimeoutsAndAwaitRequests(); await makePollRequest();
const entries = historyEntries(); expect(createFlash).toHaveBeenCalledWith({
expect(entries).toHaveLength(2); message: 'Something went wrong while fetching latest comments.',
const discussion = getDiscussion(entries, 0);
expect(discussion.notes.length).toBe(2);
expect(discussion.notes[1].note).toBe('new note');
});
}); });
});
it('creates a new discussion with a new note if the discussion does not exist', () => { it('emits the vulnerability-state-change event when the system note is new', async () => {
const note = { id: 300, note: 'new note on a new discussion', discussion_id: 3 }; await createWrapperAndFetchNotes();
createNotesRequest(note);
return axios.waitForAll().then(async () => { const handler = jest.fn();
await startTimeoutsAndAwaitRequests(); wrapper.vm.$on('vulnerability-state-change', handler);
const entries = historyEntries(); const note = { system: true, id: 1, discussion_id: 'some-new-discussion-id' };
expect(entries).toHaveLength(3); createNotesRequest([note]);
const discussion = getDiscussion(entries, 2); await makePollRequest();
expect(discussion.notes.length).toBe(1);
expect(discussion.notes[0].note).toBe('new note on a new discussion');
});
});
it('calls initUserPopovers when a new note is retrieved', () => {
expect(initUserPopovers).not.toHaveBeenCalled();
const note = { id: 300, note: 'new note on a new discussion', discussion_id: 3 };
createNotesRequest(note);
return axios.waitForAll().then(() => { expect(handler).toHaveBeenCalledTimes(1);
expect(initUserPopovers).toHaveBeenCalled(); });
}); });
});
it('shows an error if the notes poll fails', () => {
mockAxios.onGet(vulnerability.notes_url).replyOnce(500);
return axios.waitForAll().then(async () => { describe('solution card', () => {
await startTimeoutsAndAwaitRequests(); it('does show solution card when there is one', () => {
const properties = { remediations: [{ diff: [{}] }], solution: 'some solution' };
createWrapper({ properties, discussionsHandler: discussionsSuccessHandler([]) });
expect(historyEntries()).toHaveLength(2); expect(wrapper.find(SolutionCard).exists()).toBe(true);
expect(mockAxios.history.get).toHaveLength(2); expect(wrapper.find(SolutionCard).props()).toEqual({
expect(createFlash).toHaveBeenCalled(); solution: properties.solution,
}); remediation: properties.remediations[0],
hasDownload: true,
hasMr: vulnerability.hasMr,
}); });
});
it('emits the vulnerability-state-change event when the system note is new', async () => { it('does not show solution card when there is not one', () => {
const handler = jest.fn(); createWrapper();
wrapper.vm.$on('vulnerability-state-change', handler); expect(wrapper.find(SolutionCard).exists()).toBe(false);
});
const note = { system: true, id: 1, discussion_id: 3 }; });
createNotesRequest(note);
await axios.waitForAll(); describe('merge request note', () => {
it('does not show merge request note when a merge request does not exist for the vulnerability', () => {
createWrapper();
expect(findMergeRequestNote().exists()).toBe(false);
});
await startTimeoutsAndAwaitRequests(); it('shows merge request note when a merge request exists for the vulnerability', () => {
// The object itself does not matter, we just want to make sure it's passed to the issue note.
const mergeRequestFeedback = {};
expect(handler).toHaveBeenCalledTimes(1); createWrapper({ properties: { mergeRequestFeedback } });
}); expect(findMergeRequestNote().exists()).toBe(true);
expect(findMergeRequestNote().props('feedback')).toBe(mergeRequestFeedback);
}); });
}); });
describe('related issues', () => { describe('related issues', () => {
const relatedIssues = () => wrapper.find(RelatedIssues);
it('has the correct props', () => { it('has the correct props', () => {
const endpoint = Api.buildUrl(Api.vulnerabilityIssueLinksPath).replace( const endpoint = Api.buildUrl(Api.vulnerabilityIssueLinksPath).replace(
':id', ':id',
vulnerability.id, vulnerability.id,
); );
createWrapper(); createWrapper();
expect(relatedIssues().exists()).toBe(true); expect(findRelatedIssues().exists()).toBe(true);
expect(relatedIssues().props()).toMatchObject({ expect(findRelatedIssues().props()).toMatchObject({
endpoint, endpoint,
canModifyRelatedIssues: vulnerability.canModifyRelatedIssues, canModifyRelatedIssues: vulnerability.canModifyRelatedIssues,
projectPath: vulnerability.project.fullPath, projectPath: vulnerability.project.fullPath,
...@@ -282,8 +288,6 @@ describe('Vulnerability Footer', () => { ...@@ -282,8 +288,6 @@ describe('Vulnerability Footer', () => {
}); });
describe('related jira issues', () => { describe('related jira issues', () => {
const relatedJiraIssues = () => wrapper.find(RelatedJiraIssues);
describe.each` describe.each`
createJiraIssueUrl | shouldShowRelatedJiraIssues createJiraIssueUrl | shouldShowRelatedJiraIssues
${'http://foo'} | ${true} ${'http://foo'} | ${true}
...@@ -292,20 +296,19 @@ describe('Vulnerability Footer', () => { ...@@ -292,20 +296,19 @@ describe('Vulnerability Footer', () => {
'with "createJiraIssueUrl" set to "$createJiraIssueUrl"', 'with "createJiraIssueUrl" set to "$createJiraIssueUrl"',
({ createJiraIssueUrl, shouldShowRelatedJiraIssues }) => { ({ createJiraIssueUrl, shouldShowRelatedJiraIssues }) => {
beforeEach(() => { beforeEach(() => {
createWrapper( createWrapper({
{}, mountOptions: {
{
provide: { provide: {
createJiraIssueUrl, createJiraIssueUrl,
}, },
}, },
); });
}); });
it(`${ it(`${
shouldShowRelatedJiraIssues ? 'should' : 'should not' shouldShowRelatedJiraIssues ? 'should' : 'should not'
} show related Jira issues`, () => { } show related Jira issues`, () => {
expect(relatedJiraIssues().exists()).toBe(shouldShowRelatedJiraIssues); expect(findRelatedJiraIssues().exists()).toBe(shouldShowRelatedJiraIssues);
}); });
}, },
); );
...@@ -319,7 +322,7 @@ describe('Vulnerability Footer', () => { ...@@ -319,7 +322,7 @@ describe('Vulnerability Footer', () => {
it.each(vulnerabilityStates)( it.each(vulnerabilityStates)(
`shows detection note when vulnerability state is '%s'`, `shows detection note when vulnerability state is '%s'`,
(state) => { (state) => {
createWrapper({ state }); createWrapper({ properties: { state } });
expect(detectionNote().exists()).toBe(true); expect(detectionNote().exists()).toBe(true);
expect(statusDescription().props('vulnerability')).toEqual({ expect(statusDescription().props('vulnerability')).toEqual({
...@@ -337,7 +340,7 @@ describe('Vulnerability Footer', () => { ...@@ -337,7 +340,7 @@ describe('Vulnerability Footer', () => {
describe('when a vulnerability contains a details property', () => { describe('when a vulnerability contains a details property', () => {
beforeEach(() => { beforeEach(() => {
createWrapper({ details: mockDetails }); createWrapper({ properties: { details: mockDetails } });
}); });
it('renders the report section', () => { it('renders the report section', () => {
......
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