diff --git a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker_app.vue b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker_app.vue index 3ce9228a0b7862b185d1eaee2ef63d432c7d5d29..318f5090effc752cafeb94d05540b1a55760013a 100644 --- a/app/assets/javascripts/admin/database_diagnostics/components/collation_checker_app.vue +++ b/app/assets/javascripts/admin/database_diagnostics/components/collation_checker_app.vue @@ -103,7 +103,8 @@ export default { await this.fetchDbDiagnostics({ retry: true }); } catch (error) { this.clearFetchRetries(); - this.error = error.message ?? __('An error occurred while starting diagnostics'); + this.error = + error.message ?? s__('DatabaseDiagnostics|An error occurred while starting diagnostics'); } }, clearFetchRetries() { diff --git a/app/assets/javascripts/admin/database_diagnostics/components/combined_diagnostics.vue b/app/assets/javascripts/admin/database_diagnostics/components/combined_diagnostics.vue new file mode 100644 index 0000000000000000000000000000000000000000..693f4de5072fb9f7c1ffc20b4fe02b4af7c7929b --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/combined_diagnostics.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/components/schema_checker_app.vue b/app/assets/javascripts/admin/database_diagnostics/components/schema_checker_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..1c494ab9b9d748e33a5bb44cfb93e2ad1e9b07fe --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/schema_checker_app.vue @@ -0,0 +1,172 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/components/schema_issues_section.vue b/app/assets/javascripts/admin/database_diagnostics/components/schema_issues_section.vue new file mode 100644 index 0000000000000000000000000000000000000000..662af0bc3c86c5ba66c6c9d8be06ed15b8217c85 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/schema_issues_section.vue @@ -0,0 +1,156 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/components/schema_results_container.vue b/app/assets/javascripts/admin/database_diagnostics/components/schema_results_container.vue new file mode 100644 index 0000000000000000000000000000000000000000..a0f26e2e3459969284654c0e140e8df35ab4e5c7 --- /dev/null +++ b/app/assets/javascripts/admin/database_diagnostics/components/schema_results_container.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/assets/javascripts/admin/database_diagnostics/index.js b/app/assets/javascripts/admin/database_diagnostics/index.js index dde6ead447ce0db6f73b508b6a82f4cd0a2d1032..f2fa39b92b8a8a1ed117aaaf67ea3d27509b39bf 100644 --- a/app/assets/javascripts/admin/database_diagnostics/index.js +++ b/app/assets/javascripts/admin/database_diagnostics/index.js @@ -1,22 +1,29 @@ import Vue from 'vue'; -import CollationChecker from './components/collation_checker_app.vue'; +import CombinedDiagnostics from './components/combined_diagnostics.vue'; export const initDatabaseDiagnosticsApp = () => { const el = document.getElementById('js-database-diagnostics'); if (!el) return false; - const { runCollationCheckUrl, collationCheckResultsUrl } = el.dataset; + const { + runCollationCheckUrl, + collationCheckResultsUrl, + runSchemaCheckUrl, + schemaCheckResultsUrl, + } = el.dataset; return new Vue({ el, - name: 'DatabaseCollationHealthChecker', + name: 'DatabaseDiagnosticsView', provide: { runCollationCheckUrl, collationCheckResultsUrl, + runSchemaCheckUrl, + schemaCheckResultsUrl, }, render(createElement) { - return createElement(CollationChecker); + return createElement(CombinedDiagnostics); }, }); }; diff --git a/app/views/admin/database_diagnostics/index.html.haml b/app/views/admin/database_diagnostics/index.html.haml index 949e95cd998cb56c0fc88dbcee55a19b489d7648..1602759a0f92f1897b91ff09a91292bc894bf68f 100644 --- a/app/views/admin/database_diagnostics/index.html.haml +++ b/app/views/admin/database_diagnostics/index.html.haml @@ -10,6 +10,8 @@ #js-database-diagnostics{ data: { run_collation_check_url: run_collation_check_admin_database_diagnostics_path(format: :json), - collation_check_results_url: collation_check_results_admin_database_diagnostics_path(format: :json) + collation_check_results_url: collation_check_results_admin_database_diagnostics_path(format: :json), + run_schema_check_url: run_schema_check_admin_database_diagnostics_path(format: :json), + schema_check_results_url: schema_check_results_admin_database_diagnostics_path(format: :json) } } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5ca2d90dd8f47765d5fc737b4a8d1201e6554197..56850f94029f500d00f2c368a4b53068aa98e0c4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7622,9 +7622,6 @@ msgstr "" msgid "An error occurred while searching for labels, please try again." msgstr "" -msgid "An error occurred while starting diagnostics" -msgstr "" - msgid "An error occurred while subscribing to this page. Please try again later." msgstr "" @@ -21330,6 +21327,9 @@ msgstr "" msgid "Database update failed" msgstr "" +msgid "DatabaseDiagnostics|An error occurred while starting diagnostics" +msgstr "" + msgid "DatabaseDiagnostics|Collation health check" msgstr "" @@ -21342,9 +21342,21 @@ msgstr "" msgid "DatabaseDiagnostics|Database: %{name}" msgstr "" +msgid "DatabaseDiagnostics|Details" +msgstr "" + msgid "DatabaseDiagnostics|Detect collation-related index corruption issues that might occur after OS upgrade" msgstr "" +msgid "DatabaseDiagnostics|Detect database schema inconsistencies and structural issues" +msgstr "" + +msgid "DatabaseDiagnostics|Foreign keys" +msgstr "" + +msgid "DatabaseDiagnostics|Indexes" +msgstr "" + msgid "DatabaseDiagnostics|Issues detected" msgstr "" @@ -21354,12 +21366,33 @@ msgstr "" msgid "DatabaseDiagnostics|Learn more" msgstr "" +msgid "DatabaseDiagnostics|Missing items" +msgstr "" + msgid "DatabaseDiagnostics|No diagnostics have been run yet. Click \"Run Collation Check\" to analyze your database for potential collation issues." msgstr "" +msgid "DatabaseDiagnostics|No schema issues detected." +msgstr "" + msgid "DatabaseDiagnostics|Run collation check" msgstr "" +msgid "DatabaseDiagnostics|Run schema check" +msgstr "" + +msgid "DatabaseDiagnostics|Schema health check" +msgstr "" + +msgid "DatabaseDiagnostics|Select \"Run Schema Check\" to analyze your database schema for potential issues." +msgstr "" + +msgid "DatabaseDiagnostics|Sequences" +msgstr "" + +msgid "DatabaseDiagnostics|Tables" +msgstr "" + msgid "DatabaseDiagnostics|The database diagnostic job is taking longer than expected. You can check back later or try running it again." msgstr "" diff --git a/spec/frontend/admin/database_diagnostics/components/combined_diagnostics_spec.js b/spec/frontend/admin/database_diagnostics/components/combined_diagnostics_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a442a1eb72da5ce54e7556ec3d30613d5e2801d7 --- /dev/null +++ b/spec/frontend/admin/database_diagnostics/components/combined_diagnostics_spec.js @@ -0,0 +1,24 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CombinedDiagnostics from '~/admin/database_diagnostics/components/combined_diagnostics.vue'; +import CollationCheckerApp from '~/admin/database_diagnostics/components/collation_checker_app.vue'; +import SchemaCheckerApp from '~/admin/database_diagnostics/components/schema_checker_app.vue'; + +describe('CombinedDiagnostics component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(CombinedDiagnostics); + }; + + const findCollationChecker = () => wrapper.findComponent(CollationCheckerApp); + const findSchemaChecker = () => wrapper.findComponent(SchemaCheckerApp); + + beforeEach(() => { + createComponent(); + }); + + it('renders both diagnostic components', () => { + expect(findCollationChecker().exists()).toBe(true); + expect(findSchemaChecker().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/admin/database_diagnostics/components/schema_checker_app_spec.js b/spec/frontend/admin/database_diagnostics/components/schema_checker_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0328a62f274d231d636fbe8e08d0521dcc81a4a3 --- /dev/null +++ b/spec/frontend/admin/database_diagnostics/components/schema_checker_app_spec.js @@ -0,0 +1,211 @@ +import { nextTick } from 'vue'; +import { GlSkeletonLoader } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import SchemaCheckerApp from '~/admin/database_diagnostics/components/schema_checker_app.vue'; +import SchemaResultsContainer from '~/admin/database_diagnostics/components/schema_results_container.vue'; +import { schemaIssuesResults, noSchemaIssuesResults } from '../mock_data'; + +describe('SchemaCheckerApp component', () => { + let wrapper; + let mockAxios; + + const findTitle = () => wrapper.findByTestId('title'); + const findRunButton = () => wrapper.findByTestId('run-diagnostics-button'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findErrorAlert = () => wrapper.findByTestId('error-alert'); + const findNoResultsMessage = () => wrapper.findByTestId('no-results-message'); + const findLastRun = () => wrapper.findByTestId('last-run'); + const findSchemaResultsContainer = () => wrapper.findComponent(SchemaResultsContainer); + + const runSchemaCheckUrl = '/admin/database_diagnostics/run_schema_check.json'; + const schemaCheckResultsUrl = '/admin/database_diagnostics/schema_check_results.json'; + + const createComponent = () => { + wrapper = shallowMountExtended(SchemaCheckerApp, { + provide: { + runSchemaCheckUrl, + schemaCheckResultsUrl, + }, + }); + }; + + const clickRunDiagnosticsButton = async () => { + findRunButton().vm.$emit('click'); + await nextTick(); + }; + + beforeEach(() => { + jest.useFakeTimers(); + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + jest.clearAllTimers(); + }); + + describe('initial state', () => { + beforeEach(() => { + mockAxios.onGet(schemaCheckResultsUrl).reply(404); + createComponent(); + }); + + it('shows a loading indicator initially', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('renders the title', () => { + expect(findTitle().text()).toBe('Schema health check'); + expect(wrapper.text()).toContain( + 'Detect database schema inconsistencies and structural issues', + ); + }); + + it('shows no results message after loading', async () => { + await waitForPromises(); + expect(findLastRun().exists()).toBe(false); + expect(findSkeletonLoader().exists()).toBe(false); + expect(findNoResultsMessage().text()).toBe( + 'Select "Run Schema Check" to analyze your database schema for potential issues.', + ); + }); + + it('enables the run button after loading completes', async () => { + expect(findRunButton().props('disabled')).toBe(true); + + await waitForPromises(); + + expect(findRunButton().props('disabled')).toBe(false); + }); + }); + + describe('with results showing schema issues', () => { + beforeEach(async () => { + mockAxios.onGet(schemaCheckResultsUrl).reply(200, schemaIssuesResults); + createComponent(); + await waitForPromises(); + }); + + it('renders schema results container with correct props', () => { + expect(findSchemaResultsContainer().props('schemaDiagnostics')).toEqual(schemaIssuesResults); + }); + + it('displays the last run timestamp', () => { + expect(findLastRun().text()).toMatchInterpolatedText('Last checked: Jul 23, 2025, 10:00 AM'); + }); + }); + + describe('with no issues', () => { + beforeEach(async () => { + mockAxios.onGet(schemaCheckResultsUrl).reply(200, noSchemaIssuesResults); + createComponent(); + await waitForPromises(); + }); + + it('renders schema results container for the database', () => { + expect(findSchemaResultsContainer().props('schemaDiagnostics')).toEqual( + noSchemaIssuesResults, + ); + }); + }); + + describe('running diagnostics', () => { + beforeEach(async () => { + mockAxios.onGet(schemaCheckResultsUrl).reply(404); + mockAxios.onPost(runSchemaCheckUrl).reply(200); + createComponent(); + await waitForPromises(); + }); + + it('shows loading state and disables button when run button is clicked', async () => { + await clickRunDiagnosticsButton(); + await waitForPromises(); + + expect(findRunButton().props('disabled')).toBe(true); + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('makes the correct API call when run button is clicked', async () => { + await clickRunDiagnosticsButton(); + await waitForPromises(); + + expect(mockAxios.history.post).toHaveLength(1); + expect(mockAxios.history.post[0].url).toBe(runSchemaCheckUrl); + }); + + it('updates the view when results are available after fetching', async () => { + await clickRunDiagnosticsButton(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(true); + + mockAxios.onGet(schemaCheckResultsUrl).reply(200, schemaIssuesResults); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findSchemaResultsContainer().exists()).toBe(true); + }); + }); + + describe('error handling', () => { + it('displays error alert when initial API request fails', async () => { + mockAxios.onGet(schemaCheckResultsUrl).reply(500, { + error: 'Internal server error', + }); + + createComponent(); + await waitForPromises(); + + expect(findErrorAlert().text()).toBe('Internal server error'); + }); + + it('displays error alert when run diagnostic request fails', async () => { + // The component uses error.message when the post request fails + mockAxios.onPost(runSchemaCheckUrl).replyOnce(500); + + createComponent(); + await clickRunDiagnosticsButton(); + await waitForPromises(); + + expect(findErrorAlert().text()).toBe('Request failed with status code 500'); + }); + }); + + describe('schema diagnostics retry', () => { + it('stops retries after reaching the maximum attempts', async () => { + mockAxios.onGet(schemaCheckResultsUrl).reply(404); + mockAxios.onPost(runSchemaCheckUrl).reply(200); + + createComponent(); + await waitForPromises(); + + await clickRunDiagnosticsButton(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(true); + + // We need to run runOnlyPendingTimers 60 times so that we exhaust maxRetryAttempts + await [...Array(60)].reduce(async (promise) => { + await promise; + jest.runOnlyPendingTimers(); + await waitForPromises(); + }, Promise.resolve()); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findErrorAlert().text()).toBe( + 'The database diagnostic job is taking longer than expected. You can check back later or try running it again.', + ); + + // We expect 62 calls to schemaCheckResultsUrl: + // 1 (initial call) + 1 (first try after post call) + 60 (retries after post call) = 62 + expect(mockAxios.history.get.filter((req) => req.url === schemaCheckResultsUrl)).toHaveLength( + 62, + ); + }); + }); +}); diff --git a/spec/frontend/admin/database_diagnostics/components/schema_issues_section_spec.js b/spec/frontend/admin/database_diagnostics/components/schema_issues_section_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c243a6737164fa40509bc37a3be1ada8d4b6f1d8 --- /dev/null +++ b/spec/frontend/admin/database_diagnostics/components/schema_issues_section_spec.js @@ -0,0 +1,592 @@ +import { GlIcon, GlBadge, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SchemaIssuesSection from '~/admin/database_diagnostics/components/schema_issues_section.vue'; +import { schemaIssuesResults, noSchemaIssuesResults } from '../mock_data'; + +describe('SchemaIssuesSection component', () => { + let wrapper; + + const defaultProps = { + databaseResults: schemaIssuesResults.schema_check_results.main, + }; + + const findNoIssuesAlert = () => wrapper.findByTestId('no-issues-alert'); + const findIndexesCount = () => wrapper.findByTestId('indexes-count'); + const findTablesCount = () => wrapper.findByTestId('tables-count'); + const findForeignKeysCount = () => wrapper.findByTestId('foreignKeys-count'); + const findSequencesCount = () => wrapper.findByTestId('sequences-count'); + const findIndexesToggle = () => wrapper.findByTestId('indexes-toggle'); + const findTablesToggle = () => wrapper.findByTestId('tables-toggle'); + const findForeignKeysToggle = () => wrapper.findByTestId('foreignKeys-toggle'); + const findSequencesToggle = () => wrapper.findByTestId('sequences-toggle'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(SchemaIssuesSection, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when no schema issues exist', () => { + beforeEach(() => { + createComponent({ + props: { + databaseResults: noSchemaIssuesResults.schema_check_results.main, + }, + }); + }); + + it('displays success alert with correct message', () => { + expect(findNoIssuesAlert().text()).toBe('No schema issues detected.'); + expect(findNoIssuesAlert().props('variant')).toBe('success'); + expect(findNoIssuesAlert().props('dismissible')).toBe(false); + }); + + it('shows success icon in alert', () => { + const alert = findNoIssuesAlert(); + const icon = alert.findComponent(GlIcon); + + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('check-circle-filled'); + }); + + it('displays all section type labels', () => { + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('Tables'); + expect(wrapper.text()).toContain('Foreign keys'); + expect(wrapper.text()).toContain('Sequences'); + }); + + it('does not show count badges for any sections', () => { + expect(findIndexesCount().exists()).toBe(false); + expect(findTablesCount().exists()).toBe(false); + expect(findForeignKeysCount().exists()).toBe(false); + expect(findSequencesCount().exists()).toBe(false); + }); + + it('does not show toggle buttons for any sections', () => { + expect(findIndexesToggle().exists()).toBe(false); + expect(findTablesToggle().exists()).toBe(false); + expect(findForeignKeysToggle().exists()).toBe(false); + expect(findSequencesToggle().exists()).toBe(false); + }); + + it('shows success icons for all sections', () => { + const successIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'check-circle-filled'); + expect(successIcons.length).toBeGreaterThan(0); + }); + }); + + describe('when schema issues exist', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not show success alert', () => { + expect(findNoIssuesAlert().exists()).toBe(false); + }); + + it('displays correct count badges for sections with issues', () => { + expect(findIndexesCount().text()).toBe('2'); + expect(findIndexesCount().props('variant')).toBe('warning'); + + expect(findTablesCount().text()).toBe('1'); + expect(findTablesCount().props('variant')).toBe('warning'); + + expect(findForeignKeysCount().text()).toBe('1'); + expect(findForeignKeysCount().props('variant')).toBe('warning'); + + expect(findSequencesCount().text()).toBe('1'); + expect(findSequencesCount().props('variant')).toBe('warning'); + }); + + it('displays Details button text and proper configuration', () => { + const toggleButtons = [ + findIndexesToggle(), + findTablesToggle(), + findForeignKeysToggle(), + findSequencesToggle(), + ]; + + toggleButtons.forEach((button) => { + expect(button.text()).toContain('Details'); + expect(button.props('category')).toBe('tertiary'); + expect(button.props('size')).toBe('small'); + }); + }); + + it('shows mixed icon states when some sections have issues and others do not', () => { + const mixedResults = { + missing_indexes: [{ table_name: 'users' }], + missing_tables: [], + missing_foreign_keys: [{ table_name: 'merge_requests' }], + missing_sequences: [], + }; + + createComponent({ props: { databaseResults: mixedResults } }); + + const warningIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'warning'); + const successIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'check-circle-filled'); + + expect(warningIcons).toHaveLength(2); // indexes and foreignKeys have issues + expect(successIcons).toHaveLength(2); // tables and sequences have no issues + }); + + it('shows success icons when sections have no issues', () => { + createComponent({ + props: { + databaseResults: noSchemaIssuesResults.schema_check_results.main, + }, + }); + + const successIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'check-circle-filled'); + expect(successIcons.length).toBeGreaterThan(0); + }); + + it('shows warning icons for sections with issues', () => { + const warningIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'warning'); + expect(warningIcons).toHaveLength(4); // All 4 sections have issues in main DB + }); + + it('displays chevron-down icons in toggle buttons', () => { + const chevronIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'chevron-down'); + expect(chevronIcons.length).toBeGreaterThan(0); + }); + + it('displays section labels', () => { + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('Tables'); + expect(wrapper.text()).toContain('Foreign keys'); + expect(wrapper.text()).toContain('Sequences'); + }); + }); + + describe('component behavior with different data', () => { + it('handles string-type issues', () => { + const databaseResultsWithStrings = { + missing_indexes: ['simple_index_name', 'another_index'], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }; + + createComponent({ props: { databaseResults: databaseResultsWithStrings } }); + + expect(findIndexesCount().text()).toBe('2'); + expect(findTablesCount().exists()).toBe(false); + expect(findForeignKeysCount().exists()).toBe(false); + expect(findSequencesCount().exists()).toBe(false); + }); + + it('handles object-type issues with structured data', () => { + createComponent(); + + // Verify that component displays the expected counts + expect(findIndexesCount().text()).toBe('2'); + expect(findTablesCount().text()).toBe('1'); + expect(findForeignKeysCount().text()).toBe('1'); + expect(findSequencesCount().text()).toBe('1'); + }); + + it('handles mixed data with some sections having issues', () => { + const mixedResults = { + missing_indexes: [{ table_name: 'users', column_name: 'email' }], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [{ sequence_name: 'test_seq' }], + }; + + createComponent({ props: { databaseResults: mixedResults } }); + + expect(findIndexesCount().text()).toBe('1'); + expect(findTablesCount().exists()).toBe(false); + expect(findForeignKeysCount().exists()).toBe(false); + expect(findSequencesCount().text()).toBe('1'); + }); + }); + + describe('edge cases and data handling', () => { + it('handles incomplete database results gracefully', () => { + const incompleteResults = { + missing_indexes: [{ table_name: 'test' }], + missing_tables: [], + // missing_foreign_keys and missing_sequences intentionally omitted + }; + + createComponent({ props: { databaseResults: incompleteResults } }); + + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('Tables'); + expect(wrapper.text()).toContain('Foreign keys'); + expect(wrapper.text()).toContain('Sequences'); + + expect(findIndexesCount().text()).toBe('1'); + expect(findTablesCount().exists()).toBe(false); + }); + + it('handles empty arrays gracefully', () => { + const emptyResults = { + missing_indexes: [], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }; + + createComponent({ props: { databaseResults: emptyResults } }); + + expect(findNoIssuesAlert().exists()).toBe(true); + expect(findNoIssuesAlert().text()).toBe('No schema issues detected.'); + }); + + it('handles completely empty object', () => { + createComponent({ props: { databaseResults: {} } }); + + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('Tables'); + expect(wrapper.text()).toContain('Foreign keys'); + expect(wrapper.text()).toContain('Sequences'); + }); + }); + + describe('component interface', () => { + it('renders without crashing with minimal data', () => { + const minimalResults = { + missing_indexes: [], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }; + + expect(() => { + createComponent({ props: { databaseResults: minimalResults } }); + }).not.toThrow(); + + expect(wrapper.text()).toContain('No schema issues detected.'); + }); + + it('maintains consistent behavior across different data states', () => { + // Test with issues + createComponent(); + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('Details'); + + // Test without issues + createComponent({ + props: { + databaseResults: noSchemaIssuesResults.schema_check_results.main, + }, + }); + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('No schema issues detected'); + }); + + it('renders expected number of components for each data state', () => { + // With issues - should have badges and buttons + createComponent(); + const badgesWithIssues = wrapper.findAllComponents(GlBadge); + const buttonsWithIssues = wrapper.findAllComponents(GlButton); + expect(badgesWithIssues).toHaveLength(4); // One for each section with issues + expect(buttonsWithIssues).toHaveLength(4); // One toggle for each section with issues + + // Without issues - should have no badges or buttons + createComponent({ + props: { + databaseResults: noSchemaIssuesResults.schema_check_results.main, + }, + }); + const badgesWithoutIssues = wrapper.findAllComponents(GlBadge); + const buttonsWithoutIssues = wrapper.findAllComponents(GlButton); + expect(badgesWithoutIssues).toHaveLength(0); + expect(buttonsWithoutIssues).toHaveLength(0); + }); + + it('uses proper component variants for different states', () => { + createComponent(); + + // Warning badges for issues + const warningBadges = wrapper + .findAllComponents(GlBadge) + .wrappers.filter((badge) => badge.props('variant') === 'warning'); + expect(warningBadges).toHaveLength(4); + + // Tertiary buttons for toggles + const tertiaryButtons = wrapper + .findAllComponents(GlButton) + .wrappers.filter((button) => button.props('category') === 'tertiary'); + expect(tertiaryButtons).toHaveLength(4); + }); + + it('provides appropriate icon indicators for different states', () => { + // Test with data that has mixed states (some issues, some clean) + const mixedResults = { + missing_indexes: [{ table_name: 'users' }], // Has issues + missing_tables: [], // No issues + missing_foreign_keys: [], // No issues + missing_sequences: [{ sequence_name: 'test_seq' }], // Has issues + }; + + createComponent({ props: { databaseResults: mixedResults } }); + + const allIcons = wrapper.findAllComponents(GlIcon); + expect(allIcons.length).toBeGreaterThan(0); + + // Check for warning icons (sections with issues) + const warningIcons = allIcons.wrappers.filter((icon) => icon.props('name') === 'warning'); + expect(warningIcons).toHaveLength(2); // indexes and sequences have issues + + // Check for success icons (sections without issues) + const successIcons = allIcons.wrappers.filter( + (icon) => icon.props('name') === 'check-circle-filled', + ); + expect(successIcons).toHaveLength(2); // tables and foreign_keys have no issues + }); + }); + + describe('data handling variations', () => { + it('displays correct counts for different issue types', () => { + createComponent(); + + // Based on mock data structure + expect(findIndexesCount().text()).toBe('2'); + expect(findTablesCount().text()).toBe('1'); + expect(findForeignKeysCount().text()).toBe('1'); + expect(findSequencesCount().text()).toBe('1'); + }); + + it('handles arrays of different types', () => { + const mixedTypeResults = { + missing_indexes: ['string_index', { table_name: 'obj_table', column_name: 'obj_col' }], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }; + + createComponent({ props: { databaseResults: mixedTypeResults } }); + + expect(findIndexesCount().text()).toBe('2'); + expect(findIndexesToggle().exists()).toBe(true); + }); + + it('shows appropriate visual feedback based on data presence', () => { + // Test with mixed data - some sections with issues, some without + const mixedResults = { + missing_indexes: [{ table_name: 'users' }], + missing_tables: [], + missing_foreign_keys: [{ table_name: 'merge_requests' }], + missing_sequences: [], + }; + + createComponent({ props: { databaseResults: mixedResults } }); + + // Sections with issues should show badges and toggles + expect(findIndexesCount().exists()).toBe(true); + expect(findIndexesToggle().exists()).toBe(true); + expect(findForeignKeysCount().exists()).toBe(true); + expect(findForeignKeysToggle().exists()).toBe(true); + + // Sections without issues should not show badges or toggles + expect(findTablesCount().exists()).toBe(false); + expect(findTablesToggle().exists()).toBe(false); + expect(findSequencesCount().exists()).toBe(false); + expect(findSequencesToggle().exists()).toBe(false); + }); + }); + + describe('component configuration and props', () => { + it('configures toggle buttons correctly when issues exist', () => { + createComponent(); + + const toggleButtons = wrapper.findAllComponents(GlButton); + expect(toggleButtons).toHaveLength(4); + + toggleButtons.wrappers.forEach((button) => { + expect(button.props('category')).toBe('tertiary'); + expect(button.props('size')).toBe('small'); + expect(button.text()).toContain('Details'); + }); + }); + + it('configures count badges correctly', () => { + createComponent(); + + const badges = wrapper.findAllComponents(GlBadge); + expect(badges).toHaveLength(4); + + badges.wrappers.forEach((badge) => { + expect(badge.props('variant')).toBe('warning'); + }); + }); + + it('configures icons correctly for different states', () => { + // Test with mixed data to verify both icon types + const mixedResults = { + missing_indexes: [{ table_name: 'users' }], // Has issues - warning icon + missing_tables: [], // No issues - success icon + missing_foreign_keys: [], // No issues - success icon + missing_sequences: [{ sequence_name: 'test_seq' }], // Has issues - warning icon + }; + + createComponent({ props: { databaseResults: mixedResults } }); + + // Warning icons for sections with issues + const warningIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'warning'); + expect(warningIcons).toHaveLength(2); // indexes and sequences + + // Success icons for sections without issues + const successIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'check-circle-filled'); + expect(successIcons).toHaveLength(2); // tables and foreign_keys + + // Chevron icons in toggle buttons (only for sections with issues) + const chevronIcons = wrapper + .findAllComponents(GlIcon) + .wrappers.filter((icon) => icon.props('name') === 'chevron-down'); + expect(chevronIcons).toHaveLength(2); // Only sections with issues have toggle buttons + + chevronIcons.forEach((icon) => { + expect(icon.props('size')).toBe(14); + }); + }); + }); + + describe('resilience and error handling', () => { + it('handles arrays of mixed string and object types', () => { + const mixedResults = { + missing_indexes: [ + 'string_index_name', + { table_name: 'users', column_name: 'email', index_name: 'complex_index' }, + ], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }; + + expect(() => { + createComponent({ props: { databaseResults: mixedResults } }); + }).not.toThrow(); + + expect(findIndexesCount().text()).toBe('2'); + }); + + it('renders component structure consistently', () => { + createComponent(); + + // Should always render the same number of section headers + const sectionHeaders = wrapper.findAll('.gl-flex.gl-items-center.gl-justify-between'); + expect(sectionHeaders).toHaveLength(4); // indexes, tables, foreignKeys, sequences + }); + + it('maintains component contract with different prop combinations', () => { + // Test various realistic data combinations + const testCases = [ + {}, + { missing_indexes: [] }, + { missing_indexes: ['test'] }, + { + missing_indexes: [], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }, + ]; + + testCases.forEach((databaseResults) => { + expect(() => { + createComponent({ props: { databaseResults } }); + }).not.toThrow(); + + // Should always render section labels + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('Tables'); + }); + }); + }); + + describe('user experience states', () => { + it('provides clear feedback when no issues exist', () => { + createComponent({ + props: { + databaseResults: noSchemaIssuesResults.schema_check_results.main, + }, + }); + + expect(findNoIssuesAlert().props('variant')).toBe('success'); + expect(wrapper.text()).toContain('No schema issues detected'); + }); + + it('provides clear feedback when issues exist', () => { + createComponent(); + + // Should show issue counts and action buttons + expect(findIndexesCount().exists()).toBe(true); + expect(findIndexesToggle().exists()).toBe(true); + expect(findIndexesToggle().text()).toContain('Details'); + }); + + it('shows appropriate visual hierarchy', () => { + createComponent(); + + // Each section should have consistent structure + const allSections = wrapper.findAll('.gl-mb-4'); + expect(allSections).toHaveLength(4); + + // Should have proper component counts + expect(wrapper.findAllComponents(GlIcon).length).toBeGreaterThan(0); + expect(wrapper.findAllComponents(GlBadge)).toHaveLength(4); + expect(wrapper.findAllComponents(GlButton)).toHaveLength(4); + }); + }); + + describe('component rendering integrity', () => { + it('renders all expected section types regardless of data', () => { + const testData = [ + schemaIssuesResults.schema_check_results.main, + noSchemaIssuesResults.schema_check_results.main, + {}, + ]; + + testData.forEach((databaseResults) => { + createComponent({ props: { databaseResults } }); + + // Should always render these section labels + expect(wrapper.text()).toContain('Indexes'); + expect(wrapper.text()).toContain('Tables'); + expect(wrapper.text()).toContain('Foreign keys'); + expect(wrapper.text()).toContain('Sequences'); + }); + }); + + it('maintains component stability across prop changes', () => { + // Component should not crash with various prop combinations + createComponent(); + expect(wrapper.exists()).toBe(true); + + createComponent({ props: { databaseResults: {} } }); + expect(wrapper.exists()).toBe(true); + + createComponent({ + props: { + databaseResults: noSchemaIssuesResults.schema_check_results.main, + }, + }); + expect(wrapper.exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/admin/database_diagnostics/components/schema_results_container_spec.js b/spec/frontend/admin/database_diagnostics/components/schema_results_container_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..7336006e436ce70cba4913346613666d76b7a130 --- /dev/null +++ b/spec/frontend/admin/database_diagnostics/components/schema_results_container_spec.js @@ -0,0 +1,178 @@ +import { GlCard, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SchemaResultsContainer from '~/admin/database_diagnostics/components/schema_results_container.vue'; +import SchemaIssuesSection from '~/admin/database_diagnostics/components/schema_issues_section.vue'; +import { + schemaIssuesResults, + multiDatabaseResults, + singleDatabaseResults, + noSchemaIssuesResults, +} from '../mock_data'; + +describe('SchemaResultsContainer component', () => { + let wrapper; + + const defaultProps = { + schemaDiagnostics: schemaIssuesResults, + }; + + const findDatabaseSections = () => wrapper.findAll('[data-testid^="database-"]'); + const findSchemaIssuesSections = () => wrapper.findAllComponents(SchemaIssuesSection); + const findCards = () => wrapper.findAllComponents(GlCard); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(SchemaResultsContainer, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { GlCard, GlSprintf }, + }); + }; + + describe('with multiple databases', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders database sections with correct titles', () => { + const sections = findDatabaseSections(); + expect(sections).toHaveLength(2); + expect(sections.at(0).text()).toMatchInterpolatedText('Database: main'); + expect(sections.at(1).text()).toMatchInterpolatedText('Database: ci'); + }); + + it('renders SchemaIssuesSection for each database', () => { + const issuesSections = findSchemaIssuesSections(); + expect(issuesSections).toHaveLength(2); + }); + + it('passes correct database results to each SchemaIssuesSection', () => { + const issuesSections = findSchemaIssuesSections(); + + expect(issuesSections.at(0).props('databaseResults')).toEqual( + schemaIssuesResults.schema_check_results.main, + ); + expect(issuesSections.at(1).props('databaseResults')).toEqual( + schemaIssuesResults.schema_check_results.ci, + ); + }); + + it('wraps each database in a card', () => { + const cards = findCards(); + expect(cards).toHaveLength(2); + }); + }); + + describe('with single database', () => { + beforeEach(() => { + createComponent({ props: { schemaDiagnostics: singleDatabaseResults } }); + }); + + it('renders single database section', () => { + const sections = findDatabaseSections(); + expect(sections).toHaveLength(1); + expect(sections.at(0).text()).toMatchInterpolatedText('Database: main'); + }); + + it('displays single database name', () => { + const text = wrapper.text().replace(/\s+/g, ' '); // Normalize whitespace + expect(text).toContain('Database: main'); + expect(text).not.toContain('Database: ci'); + }); + + it('renders single SchemaIssuesSection', () => { + const issuesSections = findSchemaIssuesSections(); + expect(issuesSections).toHaveLength(1); + + expect(issuesSections.at(0).props('databaseResults')).toEqual( + singleDatabaseResults.schema_check_results.main, + ); + }); + }); + + describe('with many databases', () => { + beforeEach(() => { + createComponent({ props: { schemaDiagnostics: multiDatabaseResults } }); + }); + + it('renders all database sections', () => { + const sections = findDatabaseSections(); + expect(sections).toHaveLength(3); + + expect(sections.at(0).text()).toMatchInterpolatedText('Database: main'); + expect(sections.at(1).text()).toMatchInterpolatedText('Database: ci'); + expect(sections.at(2).text()).toMatchInterpolatedText('Database: registry'); + }); + + it('renders SchemaIssuesSection for all databases', () => { + const issuesSections = findSchemaIssuesSections(); + expect(issuesSections).toHaveLength(3); + + expect(issuesSections.at(0).props('databaseResults')).toEqual( + multiDatabaseResults.schema_check_results.main, + ); + expect(issuesSections.at(1).props('databaseResults')).toEqual( + multiDatabaseResults.schema_check_results.ci, + ); + expect(issuesSections.at(2).props('databaseResults')).toEqual( + multiDatabaseResults.schema_check_results.registry, + ); + }); + }); + + describe('with no issues', () => { + beforeEach(() => { + createComponent({ props: { schemaDiagnostics: noSchemaIssuesResults } }); + }); + + it('renders database section even with no issues', () => { + const sections = findDatabaseSections(); + expect(sections).toHaveLength(1); + }); + + it('passes empty results to SchemaIssuesSection', () => { + const issuesSection = findSchemaIssuesSections().at(0); + const passedResults = issuesSection.props('databaseResults'); + + expect(passedResults.missing_indexes).toEqual([]); + expect(passedResults.missing_tables).toEqual([]); + expect(passedResults.missing_foreign_keys).toEqual([]); + expect(passedResults.missing_sequences).toEqual([]); + }); + }); + + describe('edge cases', () => { + it('handles empty schema_check_results gracefully', () => { + const emptyResults = { + metadata: { last_run_at: '2025-07-23T10:00:00Z' }, + schema_check_results: {}, + }; + + createComponent({ props: { schemaDiagnostics: emptyResults } }); + + expect(findDatabaseSections()).toHaveLength(0); + expect(findSchemaIssuesSections()).toHaveLength(0); + expect(findCards()).toHaveLength(0); + }); + + it('handles database names with special characters', () => { + const specialResults = { + metadata: { last_run_at: '2025-07-23T10:00:00Z' }, + schema_check_results: { + 'database-with-dashes': { + missing_indexes: [], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }, + }, + }; + + createComponent({ props: { schemaDiagnostics: specialResults } }); + + const sections = findDatabaseSections(); + expect(sections.at(0).text()).toMatchInterpolatedText('Database: database-with-dashes'); + }); + }); +}); diff --git a/spec/frontend/admin/database_diagnostics/mock_data.js b/spec/frontend/admin/database_diagnostics/mock_data.js index 3883f23939a95d6c8f667f421e645bdbeb50229d..e2bb9484e8ada26306f2ee9b06d09db1cb5cf7c4 100644 --- a/spec/frontend/admin/database_diagnostics/mock_data.js +++ b/spec/frontend/admin/database_diagnostics/mock_data.js @@ -49,3 +49,124 @@ export const noIssuesResults = { }, }, }; + +export const schemaIssuesResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + schema_check_results: { + main: { + missing_indexes: [ + { + table_name: 'users', + column_name: 'email', + index_name: 'index_users_on_email', + }, + { + table_name: 'projects', + column_name: 'name', + index_name: 'index_projects_on_name', + }, + ], + missing_tables: [ + { + table_name: 'audit_logs', + schema: 'public', + }, + ], + missing_foreign_keys: [ + { + table_name: 'merge_requests', + column_name: 'project_id', + referenced_table: 'projects', + }, + ], + missing_sequences: [ + { + sequence_name: 'users_id_seq', + table_name: 'users', + }, + ], + }, + ci: { + missing_indexes: [ + { + table_name: 'ci_builds', + column_name: 'status', + index_name: 'index_ci_builds_on_status', + }, + ], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }, + }, +}; + +export const noSchemaIssuesResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + schema_check_results: { + main: { + missing_indexes: [], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }, + }, +}; + +export const singleDatabaseResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + schema_check_results: { + main: { + missing_indexes: [ + { + table_name: 'users', + column_name: 'email', + index_name: 'index_users_on_email', + }, + ], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }, + }, +}; + +export const multiDatabaseResults = { + metadata: { + last_run_at: '2025-07-23T10:00:00Z', + }, + schema_check_results: { + main: { + missing_indexes: [{ table_name: 'users', column_name: 'email' }], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }, + ci: { + missing_indexes: [{ table_name: 'ci_builds', index_name: 'ci_index' }], + missing_tables: [], + missing_foreign_keys: [], + missing_sequences: [], + }, + registry: { + missing_indexes: [], + missing_tables: [{ table_name: 'registry_table' }], + missing_foreign_keys: [], + missing_sequences: [], + }, + }, +}; + +// Mock data for the more complex SchemaSection component +export const complexIssueData = { + missing: ['missing_index_1', 'missing_index_2'], + extra: ['extra_column_1'], + invalid: ['invalid_constraint_1'], + inconsistent: ['inconsistent_data_1', 'inconsistent_data_2'], +};