diff --git a/spec/frontend/work_items/components/work_items_list_critical_tests_spec.js b/spec/frontend/work_items/components/work_items_list_critical_tests_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..95893d2fc5a5ff4de8d1e302badfe53f51201fc9 --- /dev/null +++ b/spec/frontend/work_items/components/work_items_list_critical_tests_spec.js @@ -0,0 +1,192 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemsListApp from '~/work_items/pages/work_items_list_app.vue'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import getWorkItemsQuery from 'ee_else_ce/work_items/graphql/list/get_work_items_full.query.graphql'; +import getWorkItemStateCountsQuery from 'ee_else_ce/work_items/graphql/list/get_work_item_state_counts.query.graphql'; +import { createRouter } from '~/work_items/router'; +import { CREATED_DESC } from '~/issues/list/constants'; +import { + FILTERED_SEARCH_TERM, + OPERATOR_IS, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_LABEL, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +Vue.use(VueApollo); +Vue.use(VueRouter); + +describe('WorkItemsListApp - Critical Tests', () => { + let wrapper; + let mockApollo; + + const mockWorkItemsResponse = { + data: { + namespace: { + id: 'gid://gitlab/Project/1', + __typename: 'Project', + name: 'Test Project', + workItems: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + __typename: 'PageInfo', + }, + }, + }, + }, + }; + + const mockCountsResponse = { + data: { + namespace: { + id: 'gid://gitlab/Project/1', + __typename: 'Project', + workItemStateCounts: { + all: 0, + opened: 0, + closed: 0, + }, + }, + }, + }; + + const defaultQueryHandler = jest.fn().mockResolvedValue(mockWorkItemsResponse); + const countsQueryHandler = jest.fn().mockResolvedValue(mockCountsResponse); + + const findIssuableList = () => wrapper.findComponent(IssuableList); + + const mountComponent = () => { + mockApollo = createMockApollo([ + [getWorkItemsQuery, defaultQueryHandler], + [getWorkItemStateCountsQuery, countsQueryHandler], + ]); + + wrapper = shallowMountExtended(WorkItemsListApp, { + router: createRouter({ fullPath: '/work_items' }), + apolloProvider: mockApollo, + provide: { + autocompleteAwardEmojisPath: '/autocomplete/award/emojis/path', + canBulkUpdate: false, + canBulkEditEpics: false, + hasBlockedIssuesFeature: false, + hasEpicsFeature: false, + hasIssuableHealthStatusFeature: false, + hasIssueWeightsFeature: false, + hasOkrsFeature: false, + hasQualityManagementFeature: false, + hasCustomFieldsFeature: false, + initialSort: CREATED_DESC, + isGroup: false, + isSignedIn: true, + showNewWorkItem: false, + fullPath: 'test-project', + projectId: 'gid://gitlab/Project/1', + }, + propsData: { + rootPageFullPath: 'test-project', + }, + }); + }; + + afterEach(() => { + wrapper?.destroy(); + }); + + describe('Critical filter functionality', () => { + it('handles search with special characters correctly', async () => { + mountComponent(); + await waitForPromises(); // Wait for initial mount query + + // Emit filter event on IssuableList with special characters in search + findIssuableList().vm.$emit('filter', [ + { + type: FILTERED_SEARCH_TERM, + value: { + data: '"exact phrase" AND (bug OR feature)', + operator: 'undefined', + }, + }, + ]); + await nextTick(); + + // Verify query was called with the search term + expect(defaultQueryHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + search: '"exact phrase" AND (bug OR feature)', + }), + ); + }); + + it('filters for unassigned items correctly', async () => { + mountComponent(); + await waitForPromises(); // Wait for initial mount query + + // Emit filter event for unassigned items + findIssuableList().vm.$emit('filter', [ + { + type: TOKEN_TYPE_ASSIGNEE, + value: { + data: 'None', // Special value for unassigned + operator: OPERATOR_IS, + }, + }, + ]); + await nextTick(); + + // Verify query was called with unassigned filter + expect(defaultQueryHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + assigneeUsername: 'None', + }), + ); + }); + + it('combines search with multiple filters correctly', async () => { + mountComponent(); + await waitForPromises(); // Wait for initial mount query + + // Emit filter event with search term and multiple filters + findIssuableList().vm.$emit('filter', [ + { + type: FILTERED_SEARCH_TERM, + value: { + data: 'regression bug', + operator: 'undefined', + }, + }, + { + type: TOKEN_TYPE_ASSIGNEE, + value: { + data: 'None', + operator: OPERATOR_IS, + }, + }, + { + type: TOKEN_TYPE_LABEL, + value: { + data: 'critical', + operator: OPERATOR_IS, + }, + }, + ]); + await nextTick(); + + // Verify query was called with combined filters + expect(defaultQueryHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + search: 'regression bug', + assigneeUsername: 'None', + labelNames: 'critical', + }), + ); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_items_list_empty_states_spec.js b/spec/frontend/work_items/components/work_items_list_empty_states_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1ace81ceb050ff6040a8736d04caed512cd45f4e --- /dev/null +++ b/spec/frontend/work_items/components/work_items_list_empty_states_spec.js @@ -0,0 +1,159 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemsListApp from '~/work_items/pages/work_items_list_app.vue'; +import getWorkItemsQuery from 'ee_else_ce/work_items/graphql/list/get_work_items_full.query.graphql'; +import { createRouter } from '~/work_items/router'; +import { defaultProvide, mockWorkItemsResponse } from './work_items_list_test_helpers'; + +Vue.use(VueApollo); +Vue.use(VueRouter); + +describe('WorkItemsListApp - Empty States', () => { + let wrapper; + let mockApollo; + const mockQueryHandler = jest.fn().mockResolvedValue(mockWorkItemsResponse); + + const createComponent = ({ searchQuery = '', filters = {}, provide = {} } = {}) => { + mockApollo = createMockApollo([[getWorkItemsQuery, mockQueryHandler]]); + wrapper = shallowMount(WorkItemsListApp, { + router: createRouter({ fullPath: '/work_items' }), + apolloProvider: mockApollo, + propsData: { + rootPageFullPath: 'test-project', + searchQuery, + filters, + }, + provide: { + ...defaultProvide, + fullPath: 'test-project', + ...provide, + }, + }); + }; + + afterEach(() => wrapper?.destroy()); + + describe('Empty value filtering', () => { + it('filters for unassigned items only', async () => { + createComponent({ filters: { assigneeId: 'unassigned' } }); + await waitForPromises(); // Wait for initial mount query + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ assigneeId: 'unassigned' }), + ); + }); + + it('filters for items with no labels', async () => { + createComponent({ filters: { labelNames: ['none'] } }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ labelNames: ['none'] }), + ); + }); + + it('filters for items with no milestone', async () => { + createComponent({ filters: { milestoneTitle: 'none' } }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ milestoneTitle: 'none' }), + ); + }); + + it('filters for items with empty description', async () => { + createComponent({ filters: { descriptionEmpty: true } }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ descriptionEmpty: true }), + ); + }); + + it('filters for completely empty work items', async () => { + createComponent({ + filters: { + assigneeId: 'unassigned', + labelNames: ['none'], + milestoneTitle: 'none', + descriptionEmpty: true, + }, + }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ + assigneeId: 'unassigned', + labelNames: ['none'], + milestoneTitle: 'none', + descriptionEmpty: true, + }), + ); + }); + + it('switches from any to none filters', async () => { + createComponent({ filters: { labelNames: ['any'] } }); + await waitForPromises(); + + await wrapper.setProps({ filters: { labelNames: ['none'] } }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ labelNames: ['none'] }), + ); + }); + + it('combines empty filters with search', async () => { + createComponent({ + searchQuery: 'TODO', + filters: { assigneeId: 'unassigned', descriptionEmpty: true }, + }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ + searchTerm: 'TODO', + assigneeId: 'unassigned', + descriptionEmpty: true, + }), + ); + }); + + it('handles multiple assignees with unassigned', async () => { + createComponent({ + filters: { assigneeUsernames: ['user1'], assigneeId: 'unassigned' }, + }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ + assigneeUsernames: ['user1'], + assigneeId: 'unassigned', + }), + ); + }); + + it('combines multiple empty value filters', async () => { + createComponent({ + filters: { + assigneeId: 'unassigned', + labelNames: ['none'], + milestoneTitle: 'none', + }, + }); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ + assigneeId: 'unassigned', + labelNames: ['none'], + milestoneTitle: 'none', + }), + ); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_items_list_performance_spec.js b/spec/frontend/work_items/components/work_items_list_performance_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c582effc81c028f93eaee7f73f9ebba092d51a0e --- /dev/null +++ b/spec/frontend/work_items/components/work_items_list_performance_spec.js @@ -0,0 +1,121 @@ +import { GlSearchBoxByType, GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemsListApp from '~/work_items/pages/work_items_list_app.vue'; +import getWorkItemsQuery from 'ee_else_ce/work_items/graphql/list/get_work_items_full.query.graphql'; +import { createRouter } from '~/work_items/router'; +import { defaultProvide, mockWorkItemsResponse } from './work_items_list_test_helpers'; + +Vue.use(VueApollo); +Vue.use(VueRouter); + +describe('WorkItemsListApp - Performance & Edge Cases', () => { + let wrapper; + let mockApollo; + const mockQueryHandler = jest.fn().mockResolvedValue(mockWorkItemsResponse); + + const createComponent = ({ workItemsQueryHandler = mockQueryHandler, provide = {} } = {}) => { + mockApollo = createMockApollo([[getWorkItemsQuery, workItemsQueryHandler]]); + wrapper = shallowMount(WorkItemsListApp, { + router: createRouter({ fullPath: '/work_items' }), + apolloProvider: mockApollo, + propsData: { + rootPageFullPath: 'test-project', + }, + provide: { + ...defaultProvide, + fullPath: 'test-project', + ...provide, + }, + }); + }; + + afterEach(() => { + wrapper?.destroy(); + jest.clearAllTimers(); + }); + + describe('Performance scenarios', () => { + it('handles 1000+ search results', async () => { + const largeDataset = Array.from({ length: 1500 }, (_, i) => ({ + id: `gid://gitlab/WorkItem/${i}`, + title: `Item ${i}`, + })); + + const largeHandler = jest.fn().mockResolvedValue({ + data: { + workspace: { + workItems: { + nodes: largeDataset, + pageInfo: { hasNextPage: true }, + }, + }, + }, + }); + + createComponent({ workItemsQueryHandler: largeHandler }); + await waitForPromises(); // Wait for initial mount query + + expect(largeHandler).toHaveBeenCalled(); + }); + + it('debounces rapid search typing', async () => { + jest.useFakeTimers(); + createComponent(); + const searchBox = wrapper.findComponent(GlSearchBoxByType); + + ['t', 'te', 'tes', 'test'].forEach((text) => { + searchBox.vm.$emit('input', text); + }); + + expect(mockQueryHandler).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(300); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('handles network timeout gracefully', async () => { + const timeoutHandler = jest.fn().mockRejectedValue(new Error('Network timeout')); + createComponent({ workItemsQueryHandler: timeoutHandler }); + + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', 'timeout test'); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); + }); + + it('handles GraphQL errors correctly', async () => { + const errorHandler = jest.fn().mockRejectedValue({ + errors: [{ message: 'GraphQL error' }], + }); + + createComponent({ workItemsQueryHandler: errorHandler }); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); + }); + + it('handles permission changes mid-session', async () => { + createComponent(); + await waitForPromises(); + + mockQueryHandler.mockRejectedValueOnce({ + errors: [{ message: 'Forbidden', extensions: { code: 'FORBIDDEN' } }], + }); + + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', 'restricted'); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_items_list_search_filters_spec.js b/spec/frontend/work_items/components/work_items_list_search_filters_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a9e331d2afecbe75d55837d26fb03c3b3755a794 --- /dev/null +++ b/spec/frontend/work_items/components/work_items_list_search_filters_spec.js @@ -0,0 +1,253 @@ +import { GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemsListApp from '~/work_items/pages/work_items_list_app.vue'; +import getWorkItemsQuery from 'ee_else_ce/work_items/graphql/list/get_work_items_full.query.graphql'; +import { createRouter } from '~/work_items/router'; +import { defaultProvide, mockWorkItemsResponse } from './work_items_list_test_helpers'; + +Vue.use(VueApollo); +Vue.use(VueRouter); + +describe('WorkItemsListApp - Search and Filters', () => { + let wrapper; + let mockApollo; + const mockQueryHandler = jest.fn().mockResolvedValue(mockWorkItemsResponse); + + const createComponent = ({ searchQuery = '', filters = {}, provide = {} } = {}) => { + mockApollo = createMockApollo([[getWorkItemsQuery, mockQueryHandler]]); + wrapper = shallowMount(WorkItemsListApp, { + router: createRouter({ fullPath: '/work_items' }), + apolloProvider: mockApollo, + propsData: { + rootPageFullPath: 'test-project', + searchQuery, + filters, + }, + provide: { + ...defaultProvide, + fullPath: 'test-project', + ...provide, + }, + }); + }; + + afterEach(() => wrapper?.destroy()); + + describe('Search functionality', () => { + it('handles exact phrase search with quotes', async () => { + createComponent(); + await waitForPromises(); // Wait for initial mount query + + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', '"exact phrase"'); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ searchTerm: '"exact phrase"' }), + ); + }); + + it('handles wildcard pattern matching', async () => { + createComponent(); + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', '*bug*'); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ searchTerm: '*bug*' }), + ); + }); + + it('handles boolean operators', async () => { + createComponent(); + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', 'epic AND (bug OR feature)'); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ searchTerm: 'epic AND (bug OR feature)' }), + ); + }); + + it('handles escape sequences', async () => { + createComponent(); + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', '\\[blocked\\]'); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ searchTerm: '\\[blocked\\]' }), + ); + }); + + it('handles Unicode and emoji', async () => { + createComponent(); + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', '🐛 工作 bug'); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ searchTerm: '🐛 工作 bug' }), + ); + }); + + it('sanitizes XSS attempts', async () => { + createComponent(); + const searchBox = wrapper.findComponent(GlSearchBoxByType); + searchBox.vm.$emit('input', ''); + await waitForPromises(); + + expect(mockQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ searchTerm: '' }), + ); + expect(wrapper.html()).not.toContain('