summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-14 09:09:02 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-14 09:09:02 +0000
commit846a84f2e9d6149b00c63ccae2850421f6766bac (patch)
treea23327af14cdd705bdde5ae2ed1b8750ba04755e /spec
parent8e42824b115f56679b9c791570b27d6184fecad9 (diff)
downloadgitlab-ce-846a84f2e9d6149b00c63ccae2850421f6766bac.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/factories/namespaces.rb30
-rw-r--r--spec/frontend/design_management/utils/tracking_spec.js28
-rw-r--r--spec/frontend/design_management_new/utils/tracking_spec.js28
-rw-r--r--spec/frontend/ref/components/ref_selector_spec.js532
-rw-r--r--spec/models/namespace/traversal_hierarchy_spec.rb63
-rw-r--r--spec/support/shared_examples/namespaces/hierarchy_examples.rb21
6 files changed, 680 insertions, 22 deletions
diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb
index 09dbe16ef9e..f4d5848e878 100644
--- a/spec/factories/namespaces.rb
+++ b/spec/factories/namespaces.rb
@@ -29,5 +29,35 @@ FactoryBot.define do
trait :with_root_storage_statistics do
association :root_storage_statistics, factory: :namespace_root_storage_statistics
end
+
+ # Construct a hierarchy underneath the namespace.
+ # Each namespace will have `children` amount of children,
+ # and `depth` levels of descendants.
+ trait :with_hierarchy do
+ transient do
+ children { 4 }
+ depth { 4 }
+ end
+
+ after(:create) do |namespace, evaluator|
+ def create_graph(parent: nil, children: 4, depth: 4)
+ return unless depth > 1
+
+ children.times do
+ factory_name = parent.model_name.singular
+ child = FactoryBot.create(factory_name, parent: parent)
+ create_graph(parent: child, children: children, depth: depth - 1)
+ end
+
+ parent
+ end
+
+ create_graph(
+ parent: namespace,
+ children: evaluator.children,
+ depth: evaluator.depth
+ )
+ end
+ end
end
end
diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js
index 9fa5eae55b3..0549fb44956 100644
--- a/spec/frontend/design_management/utils/tracking_spec.js
+++ b/spec/frontend/design_management/utils/tracking_spec.js
@@ -8,7 +8,7 @@ function getTrackingSpy(key) {
describe('Tracking Events', () => {
describe('trackDesignDetailView', () => {
const eventKey = 'projects:issues:design';
- const eventName = 'design_viewed';
+ const eventName = 'view_design';
it('trackDesignDetailView fires a tracking event when called', () => {
const trackingSpy = getTrackingSpy(eventKey);
@@ -20,11 +20,14 @@ describe('Tracking Events', () => {
eventName,
expect.objectContaining({
label: eventName,
- value: {
- 'internal-object-refrerer': '',
- 'design-collection-owner': '',
- 'design-version-number': 1,
- 'design-is-current-version': false,
+ context: {
+ schema: expect.any(String),
+ data: {
+ 'design-version-number': 1,
+ 'design-is-current-version': false,
+ 'internal-object-referrer': '',
+ 'design-collection-owner': '',
+ },
},
}),
);
@@ -40,11 +43,14 @@ describe('Tracking Events', () => {
eventName,
expect.objectContaining({
label: eventName,
- value: {
- 'internal-object-refrerer': 'from-a-test',
- 'design-collection-owner': 'test',
- 'design-version-number': 100,
- 'design-is-current-version': true,
+ context: {
+ schema: expect.any(String),
+ data: {
+ 'design-version-number': 100,
+ 'design-is-current-version': true,
+ 'internal-object-referrer': 'from-a-test',
+ 'design-collection-owner': 'test',
+ },
},
}),
);
diff --git a/spec/frontend/design_management_new/utils/tracking_spec.js b/spec/frontend/design_management_new/utils/tracking_spec.js
index 073cc0df255..ac7267642cb 100644
--- a/spec/frontend/design_management_new/utils/tracking_spec.js
+++ b/spec/frontend/design_management_new/utils/tracking_spec.js
@@ -8,7 +8,7 @@ function getTrackingSpy(key) {
describe('Tracking Events', () => {
describe('trackDesignDetailView', () => {
const eventKey = 'projects:issues:design';
- const eventName = 'design_viewed';
+ const eventName = 'view_design';
it('trackDesignDetailView fires a tracking event when called', () => {
const trackingSpy = getTrackingSpy(eventKey);
@@ -20,11 +20,14 @@ describe('Tracking Events', () => {
eventName,
expect.objectContaining({
label: eventName,
- value: {
- 'internal-object-refrerer': '',
- 'design-collection-owner': '',
- 'design-version-number': 1,
- 'design-is-current-version': false,
+ context: {
+ schema: expect.any(String),
+ data: {
+ 'design-version-number': 1,
+ 'design-is-current-version': false,
+ 'internal-object-referrer': '',
+ 'design-collection-owner': '',
+ },
},
}),
);
@@ -40,11 +43,14 @@ describe('Tracking Events', () => {
eventName,
expect.objectContaining({
label: eventName,
- value: {
- 'internal-object-refrerer': 'from-a-test',
- 'design-collection-owner': 'test',
- 'design-version-number': 100,
- 'design-is-current-version': true,
+ context: {
+ schema: expect.any(String),
+ data: {
+ 'design-version-number': 100,
+ 'design-is-current-version': true,
+ 'internal-object-referrer': 'from-a-test',
+ 'design-collection-owner': 'test',
+ },
},
}),
);
diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js
new file mode 100644
index 00000000000..2688e4b3428
--- /dev/null
+++ b/spec/frontend/ref/components/ref_selector_spec.js
@@ -0,0 +1,532 @@
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem, GlIcon } from '@gitlab/ui';
+import { trimText } from 'helpers/text_helper';
+import { sprintf } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants';
+import createStore from '~/ref/stores/';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Ref selector component', () => {
+ const fixtures = {
+ branches: getJSONFixture('api/branches/branches.json'),
+ tags: getJSONFixture('api/tags/tags.json'),
+ commit: getJSONFixture('api/commits/commit.json'),
+ };
+
+ const projectId = '8';
+
+ let wrapper;
+ let branchesApiCallSpy;
+ let tagsApiCallSpy;
+ let commitApiCallSpy;
+
+ const createComponent = () => {
+ wrapper = mount(RefSelector, {
+ propsData: {
+ projectId,
+ value: '',
+ },
+ listeners: {
+ // simulate a parent component v-model binding
+ input: selectedRef => {
+ wrapper.setProps({ value: selectedRef });
+ },
+ },
+ stubs: {
+ GlSearchBoxByType: true,
+ },
+ localVue,
+ store: createStore(),
+ });
+ };
+
+ beforeEach(() => {
+ const mock = new MockAdapter(axios);
+ gon.api_version = 'v4';
+
+ branchesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, fixtures.branches, { [X_TOTAL_HEADER]: '123' }]);
+ tagsApiCallSpy = jest.fn().mockReturnValue([200, fixtures.tags, { [X_TOTAL_HEADER]: '456' }]);
+ commitApiCallSpy = jest.fn().mockReturnValue([200, fixtures.commit]);
+
+ mock
+ .onGet(`/api/v4/projects/${projectId}/repository/branches`)
+ .reply(config => branchesApiCallSpy(config));
+ mock
+ .onGet(`/api/v4/projects/${projectId}/repository/tags`)
+ .reply(config => tagsApiCallSpy(config));
+ mock
+ .onGet(new RegExp(`/api/v4/projects/${projectId}/repository/commits/.*`))
+ .reply(config => commitApiCallSpy(config));
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ //
+ // Finders
+ //
+ const findButtonContent = () => wrapper.find('[data-testid="button-content"]');
+
+ const findNoResults = () => wrapper.find('[data-testid="no-results"]');
+
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]');
+ const findBranchDropdownItems = () => findBranchesSection().findAll(GlNewDropdownItem);
+ const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0);
+
+ const findTagsSection = () => wrapper.find('[data-testid="tags-section"]');
+ const findTagDropdownItems = () => findTagsSection().findAll(GlNewDropdownItem);
+ const findFirstTagDropdownItem = () => findTagDropdownItems().at(0);
+
+ const findCommitsSection = () => wrapper.find('[data-testid="commits-section"]');
+ const findCommitDropdownItems = () => findCommitsSection().findAll(GlNewDropdownItem);
+ const findFirstCommitDropdownItem = () => findCommitDropdownItems().at(0);
+
+ //
+ // Expecters
+ //
+ const branchesSectionContainsErrorMessage = () => {
+ const branchesSection = findBranchesSection();
+
+ return branchesSection.text().includes(DEFAULT_I18N.branchesErrorMessage);
+ };
+
+ const tagsSectionContainsErrorMessage = () => {
+ const tagsSection = findTagsSection();
+
+ return tagsSection.text().includes(DEFAULT_I18N.tagsErrorMessage);
+ };
+
+ const commitsSectionContainsErrorMessage = () => {
+ const commitsSection = findCommitsSection();
+
+ return commitsSection.text().includes(DEFAULT_I18N.commitsErrorMessage);
+ };
+
+ //
+ // Convenience methods
+ //
+ const updateQuery = newQuery => {
+ wrapper.find(GlSearchBoxByType).vm.$emit('input', newQuery);
+ };
+
+ const selectFirstBranch = () => {
+ findFirstBranchDropdownItem().vm.$emit('click');
+ };
+
+ const selectFirstTag = () => {
+ findFirstTagDropdownItem().vm.$emit('click');
+ };
+
+ const selectFirstCommit = () => {
+ findFirstCommitDropdownItem().vm.$emit('click');
+ };
+
+ const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) =>
+ axios.waitForAll().then(() => {
+ if (andClearMocks) {
+ branchesApiCallSpy.mockClear();
+ tagsApiCallSpy.mockClear();
+ commitApiCallSpy.mockClear();
+ }
+ });
+
+ describe('initialization behavior', () => {
+ beforeEach(createComponent);
+
+ it('initializes the dropdown with branches and tags when mounted', () => {
+ return waitForRequests().then(() => {
+ expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
+ expect(tagsApiCallSpy).toHaveBeenCalledTimes(1);
+ expect(commitApiCallSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows a spinner while network requests are in progress', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+
+ return waitForRequests().then(() => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('post-initialization behavior', () => {
+ describe('when the search query is updated', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests({ andClearMocks: true });
+ });
+
+ it('requeries the endpoints when the search query is updated', () => {
+ updateQuery('v1.2.3');
+
+ return waitForRequests().then(() => {
+ expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
+ expect(tagsApiCallSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("does not make a call to the commit endpoint if the query doesn't look like a SHA", () => {
+ updateQuery('not a sha');
+
+ return waitForRequests().then(() => {
+ expect(commitApiCallSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ it('searches for a commit if the query could potentially be a SHA', () => {
+ updateQuery('abcdef');
+
+ return waitForRequests().then(() => {
+ expect(commitApiCallSpy).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when no results are found', () => {
+ beforeEach(() => {
+ branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+ tagsApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+ commitApiCallSpy = jest.fn().mockReturnValue([404]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ describe('when the search query is empty', () => {
+ it('renders a "no results" message', () => {
+ expect(findNoResults().text()).toBe(DEFAULT_I18N.noResults);
+ });
+ });
+
+ describe('when the search query is not empty', () => {
+ const query = 'hello';
+
+ beforeEach(() => {
+ updateQuery(query);
+
+ return waitForRequests();
+ });
+
+ it('renders a "no results" message that includes the search query', () => {
+ expect(findNoResults().text()).toBe(sprintf(DEFAULT_I18N.noResultsWithQuery, { query }));
+ });
+ });
+ });
+
+ describe('branches', () => {
+ describe('when the branches search returns results', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders the branches section in the dropdown', () => {
+ expect(findBranchesSection().exists()).toBe(true);
+ });
+
+ it('renders the "Branches" heading with a total number indicator', () => {
+ expect(
+ findBranchesSection()
+ .find('[data-testid="section-header"]')
+ .text(),
+ ).toBe('Branches 123');
+ });
+
+ it("does not render an error message in the branches section's body", () => {
+ expect(branchesSectionContainsErrorMessage()).toBe(false);
+ });
+
+ it('renders each non-default branch as a selectable item', () => {
+ const dropdownItems = findBranchDropdownItems();
+
+ fixtures.branches.forEach((b, i) => {
+ if (!b.default) {
+ expect(dropdownItems.at(i).text()).toBe(b.name);
+ }
+ });
+ });
+
+ it('renders the default branch as a selectable item with a "default" badge', () => {
+ const dropdownItems = findBranchDropdownItems();
+
+ const defaultBranch = fixtures.branches.find(b => b.default);
+ const defaultBranchIndex = fixtures.branches.indexOf(defaultBranch);
+
+ expect(trimText(dropdownItems.at(defaultBranchIndex).text())).toBe(
+ `${defaultBranch.name} default`,
+ );
+ });
+ });
+
+ describe('when the branches search returns no results', () => {
+ beforeEach(() => {
+ branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('does not render the branches section in the dropdown', () => {
+ expect(findBranchesSection().exists()).toBe(false);
+ });
+ });
+
+ describe('when the branches search returns an error', () => {
+ beforeEach(() => {
+ branchesApiCallSpy = jest.fn().mockReturnValue([500]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders the branches section in the dropdown', () => {
+ expect(findBranchesSection().exists()).toBe(true);
+ });
+
+ it("renders an error message in the branches section's body", () => {
+ expect(branchesSectionContainsErrorMessage()).toBe(true);
+ });
+ });
+ });
+
+ describe('tags', () => {
+ describe('when the tags search returns results', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders the tags section in the dropdown', () => {
+ expect(findTagsSection().exists()).toBe(true);
+ });
+
+ it('renders the "Tags" heading with a total number indicator', () => {
+ expect(
+ findTagsSection()
+ .find('[data-testid="section-header"]')
+ .text(),
+ ).toBe('Tags 456');
+ });
+
+ it("does not render an error message in the tags section's body", () => {
+ expect(tagsSectionContainsErrorMessage()).toBe(false);
+ });
+
+ it('renders each tag as a selectable item', () => {
+ const dropdownItems = findTagDropdownItems();
+
+ fixtures.tags.forEach((t, i) => {
+ expect(dropdownItems.at(i).text()).toBe(t.name);
+ });
+ });
+ });
+
+ describe('when the tags search returns no results', () => {
+ beforeEach(() => {
+ tagsApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('does not render the tags section in the dropdown', () => {
+ expect(findTagsSection().exists()).toBe(false);
+ });
+ });
+
+ describe('when the tags search returns an error', () => {
+ beforeEach(() => {
+ tagsApiCallSpy = jest.fn().mockReturnValue([500]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders the tags section in the dropdown', () => {
+ expect(findTagsSection().exists()).toBe(true);
+ });
+
+ it("renders an error message in the tags section's body", () => {
+ expect(tagsSectionContainsErrorMessage()).toBe(true);
+ });
+ });
+ });
+
+ describe('commits', () => {
+ describe('when the commit search returns results', () => {
+ beforeEach(() => {
+ createComponent();
+
+ updateQuery('abcd1234');
+
+ return waitForRequests();
+ });
+
+ it('renders the commit section in the dropdown', () => {
+ expect(findCommitsSection().exists()).toBe(true);
+ });
+
+ it('renders the "Commits" heading with a total number indicator', () => {
+ expect(
+ findCommitsSection()
+ .find('[data-testid="section-header"]')
+ .text(),
+ ).toBe('Commits 1');
+ });
+
+ it("does not render an error message in the comits section's body", () => {
+ expect(commitsSectionContainsErrorMessage()).toBe(false);
+ });
+
+ it('renders each commit as a selectable item with the short SHA and commit title', () => {
+ const dropdownItems = findCommitDropdownItems();
+
+ const { commit } = fixtures;
+
+ expect(dropdownItems.at(0).text()).toBe(`${commit.short_id} ${commit.title}`);
+ });
+ });
+
+ describe('when the commit search returns no results (i.e. a 404)', () => {
+ beforeEach(() => {
+ commitApiCallSpy = jest.fn().mockReturnValue([404]);
+
+ createComponent();
+
+ updateQuery('abcd1234');
+
+ return waitForRequests();
+ });
+
+ it('does not render the commits section in the dropdown', () => {
+ expect(findCommitsSection().exists()).toBe(false);
+ });
+ });
+
+ describe('when the commit search returns an error (other than a 404)', () => {
+ beforeEach(() => {
+ commitApiCallSpy = jest.fn().mockReturnValue([500]);
+
+ createComponent();
+
+ updateQuery('abcd1234');
+
+ return waitForRequests();
+ });
+
+ it('renders the commits section in the dropdown', () => {
+ expect(findCommitsSection().exists()).toBe(true);
+ });
+
+ it("renders an error message in the commits section's body", () => {
+ expect(commitsSectionContainsErrorMessage()).toBe(true);
+ });
+ });
+ });
+
+ describe('selection', () => {
+ beforeEach(() => {
+ createComponent();
+
+ updateQuery(fixtures.commit.short_id);
+
+ return waitForRequests();
+ });
+
+ it('renders a checkmark by the selected item', () => {
+ expect(findFirstBranchDropdownItem().find(GlIcon).element).toHaveClass(
+ 'gl-visibility-hidden',
+ );
+
+ selectFirstBranch();
+
+ return localVue.nextTick().then(() => {
+ expect(findFirstBranchDropdownItem().find(GlIcon).element).not.toHaveClass(
+ 'gl-visibility-hidden',
+ );
+ });
+ });
+
+ describe('when a branch is seleceted', () => {
+ it("displays the branch name in the dropdown's button", () => {
+ expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected);
+
+ selectFirstBranch();
+
+ return localVue.nextTick().then(() => {
+ expect(findButtonContent().text()).toBe(fixtures.branches[0].name);
+ });
+ });
+
+ it("updates the v-model binding with the branch's name", () => {
+ expect(wrapper.vm.value).toEqual('');
+
+ selectFirstBranch();
+
+ expect(wrapper.vm.value).toEqual(fixtures.branches[0].name);
+ });
+ });
+
+ describe('when a tag is seleceted', () => {
+ it("displays the tag name in the dropdown's button", () => {
+ expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected);
+
+ selectFirstTag();
+
+ return localVue.nextTick().then(() => {
+ expect(findButtonContent().text()).toBe(fixtures.tags[0].name);
+ });
+ });
+
+ it("updates the v-model binding with the tag's name", () => {
+ expect(wrapper.vm.value).toEqual('');
+
+ selectFirstTag();
+
+ expect(wrapper.vm.value).toEqual(fixtures.tags[0].name);
+ });
+ });
+
+ describe('when a commit is selected', () => {
+ it("displays the full SHA in the dropdown's button", () => {
+ expect(findButtonContent().text()).toBe(DEFAULT_I18N.noRefSelected);
+
+ selectFirstCommit();
+
+ return localVue.nextTick().then(() => {
+ expect(findButtonContent().text()).toBe(fixtures.commit.id);
+ });
+ });
+
+ it("updates the v-model binding with the commit's full SHA", () => {
+ expect(wrapper.vm.value).toEqual('');
+
+ selectFirstCommit();
+
+ expect(wrapper.vm.value).toEqual(fixtures.commit.id);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/models/namespace/traversal_hierarchy_spec.rb b/spec/models/namespace/traversal_hierarchy_spec.rb
new file mode 100644
index 00000000000..71b0e974106
--- /dev/null
+++ b/spec/models/namespace/traversal_hierarchy_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespace::TraversalHierarchy, type: :model do
+ let_it_be(:root, reload: true) { create(:namespace, :with_hierarchy) }
+
+ describe '.for_namespace' do
+ let(:hierarchy) { described_class.for_namespace(namespace) }
+
+ context 'with root group' do
+ let(:namespace) { root }
+
+ it { expect(hierarchy.root).to eq root }
+ end
+
+ context 'with child group' do
+ let(:namespace) { root.children.first.children.first }
+
+ it { expect(hierarchy.root).to eq root }
+ end
+
+ context 'with group outside of hierarchy' do
+ let(:namespace) { create(:namespace) }
+
+ it { expect(hierarchy.root).not_to eq root }
+ end
+ end
+
+ describe '.new' do
+ let(:hierarchy) { described_class.new(namespace) }
+
+ context 'with root group' do
+ let(:namespace) { root }
+
+ it { expect(hierarchy.root).to eq root }
+ end
+
+ context 'with child group' do
+ let(:namespace) { root.children.first }
+
+ it { expect { hierarchy }.to raise_error(StandardError, 'Must specify a root node') }
+ end
+ end
+
+ describe '#incorrect_traversal_ids' do
+ subject { described_class.new(root).incorrect_traversal_ids }
+
+ it { is_expected.to match_array Namespace.all }
+ end
+
+ describe '#sync_traversal_ids!' do
+ let(:hierarchy) { described_class.new(root) }
+
+ before do
+ hierarchy.sync_traversal_ids!
+ root.reload
+ end
+
+ it_behaves_like 'hierarchy with traversal_ids'
+ it { expect(hierarchy.incorrect_traversal_ids).to be_empty }
+ end
+end
diff --git a/spec/support/shared_examples/namespaces/hierarchy_examples.rb b/spec/support/shared_examples/namespaces/hierarchy_examples.rb
new file mode 100644
index 00000000000..d5754f47be2
--- /dev/null
+++ b/spec/support/shared_examples/namespaces/hierarchy_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'hierarchy with traversal_ids' do
+ # A convenient null node to represent the parent of root.
+ let(:null_node) { double(traversal_ids: []) }
+
+ # Walk the tree to assert that the current_node's traversal_id is always
+ # present and equal to it's parent's traversal_ids plus it's own ID.
+ def validate_traversal_ids(current_node, parent = null_node)
+ expect(current_node.traversal_ids).to be_present
+ expect(current_node.traversal_ids).to eq parent.traversal_ids + [current_node.id]
+
+ current_node.children.each do |child|
+ validate_traversal_ids(child, current_node)
+ end
+ end
+
+ it 'will be valid' do
+ validate_traversal_ids(root)
+ end
+end