Adding Feature flag management
This commit is contained in:
@@ -10,6 +10,24 @@ global.ResizeObserver = class ResizeObserver {
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
// Stub visualViewport which jsdom doesn't implement (required by Vuetify VOverlay)
|
||||
if (!window.visualViewport) {
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
value: {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
pageLeft: 0,
|
||||
pageTop: 0,
|
||||
scale: 1,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Stub CSS.supports
|
||||
Object.defineProperty(window, 'CSS', {
|
||||
value: { supports: () => false },
|
||||
|
||||
238
src/admin/tests/unit/components/features/FeatureDetail.spec.ts
Normal file
238
src/admin/tests/unit/components/features/FeatureDetail.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import FeatureDetail from '@/components/features/FeatureDetail.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { FeatureResponse, FeatureStateResponse, ProjectResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/features');
|
||||
jest.mock('@/api/featureStates');
|
||||
jest.mock('@/api/tags');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as featuresApi from '@/api/features';
|
||||
import * as featureStatesApi from '@/api/featureStates';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockFeature: FeatureResponse = {
|
||||
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null,
|
||||
description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockFeatureState: FeatureStateResponse = {
|
||||
id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null,
|
||||
enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
const tagsStub = { template: '<div data-testid="tags-chip-input" />' };
|
||||
|
||||
function mountDetail(props: Record<string, unknown> = {}) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(FeatureDetail, {
|
||||
props: { modelValue: true, feature: mockFeature, featureState: null, ...props },
|
||||
global: { plugins: [router], stubs: { 'v-dialog': dialogStub, TagsChipInput: tagsStub } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('FeatureDetail', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment({ id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '' });
|
||||
});
|
||||
|
||||
describe('WhenOpenedWithFeature', () => {
|
||||
it('ThenFeatureDetailCardIsRendered', () => {
|
||||
const wrapper = mountDetail();
|
||||
expect(wrapper.find('[data-testid="feature-detail"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenFeatureNameIsDisplayed', () => {
|
||||
const wrapper = mountDetail();
|
||||
expect(wrapper.text()).toContain('dark_mode');
|
||||
});
|
||||
|
||||
it('ThenValueAndSettingsTabsArePresent', () => {
|
||||
const wrapper = mountDetail();
|
||||
expect(wrapper.find('[data-testid="tab-value"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="tab-settings"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenNoEnvironmentIsSelected', () => {
|
||||
it('ThenSelectEnvironmentMessageIsShown', () => {
|
||||
const wrapper = mountDetail({ featureState: null });
|
||||
expect(wrapper.text()).toContain('Select an environment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenFeatureStateIsProvided', () => {
|
||||
it('ThenEnabledToggleIsRendered', () => {
|
||||
const wrapper = mountDetail({ featureState: mockFeatureState });
|
||||
expect(wrapper.find('[data-testid="feature-enabled-toggle"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenPatchFeatureStateIsCalledOnToggle', async () => {
|
||||
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue({ ...mockFeatureState, enabled: true });
|
||||
|
||||
const wrapper = mountDetail({ featureState: mockFeatureState });
|
||||
|
||||
await (wrapper.find('[data-testid="feature-enabled-toggle"]').findComponent({ name: 'VSwitch' }) as VueWrapper<any>).vm.$emit('update:modelValue', true);
|
||||
await flushPromises();
|
||||
|
||||
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith('env-dev', mockFeatureState.id, { enabled: true });
|
||||
});
|
||||
|
||||
it('ThenStateUpdatedEventIsEmittedAfterToggle', async () => {
|
||||
const updatedState = { ...mockFeatureState, enabled: true };
|
||||
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updatedState);
|
||||
|
||||
const wrapper = mountDetail({ featureState: mockFeatureState });
|
||||
|
||||
await (wrapper.find('[data-testid="feature-enabled-toggle"]').findComponent({ name: 'VSwitch' }) as VueWrapper<any>).vm.$emit('update:modelValue', true);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('stateUpdated')).toBeTruthy();
|
||||
expect(wrapper.emitted('stateUpdated')![0]).toEqual([updatedState]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenSaveValueIsClicked', () => {
|
||||
it('ThenPatchFeatureStateIsCalledWithEditedValue', async () => {
|
||||
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: 'old_value' };
|
||||
const updated = { ...stateWithValue, value: 'new_value' };
|
||||
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updated);
|
||||
|
||||
const wrapper = mountDetail({ featureState: stateWithValue });
|
||||
|
||||
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', 'new_value');
|
||||
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith('env-dev', stateWithValue.id, { value: 'new_value' });
|
||||
});
|
||||
|
||||
it('ThenStateUpdatedEventIsEmittedAfterValueSave', async () => {
|
||||
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: 'old_value' };
|
||||
const updated = { ...stateWithValue, value: 'new_value' };
|
||||
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updated);
|
||||
|
||||
const wrapper = mountDetail({ featureState: stateWithValue });
|
||||
|
||||
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', 'new_value');
|
||||
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('stateUpdated')).toBeTruthy();
|
||||
expect(wrapper.emitted('stateUpdated')![0]).toEqual([updated]);
|
||||
});
|
||||
|
||||
it('ThenSaveIsBlockedWhenValueIsInvalidJson', async () => {
|
||||
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: '{"key": "val"}' };
|
||||
|
||||
const wrapper = mountDetail({ featureState: stateWithValue });
|
||||
|
||||
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', '{bad json}');
|
||||
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(featureStatesApi.patchFeatureState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenSettingsFormIsSubmitted', () => {
|
||||
it('ThenPatchFeatureIsCalledWithFormValues', async () => {
|
||||
const updated = { ...mockFeature, description: 'Updated desc' };
|
||||
jest.mocked(featuresApi.patchFeature).mockResolvedValue(updated);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
|
||||
// Switch to settings tab
|
||||
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="settings-description-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Updated desc');
|
||||
await wrapper.find('[data-testid="save-settings-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(featuresApi.patchFeature).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
mockFeature.id,
|
||||
expect.objectContaining({ name: mockFeature.name }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ThenUpdatedEventIsEmittedAfterSave', async () => {
|
||||
const updated = { ...mockFeature, description: 'Updated desc' };
|
||||
jest.mocked(featuresApi.patchFeature).mockResolvedValue(updated);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
|
||||
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-settings-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('updated')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenDeleteConfirmationIsEntered', () => {
|
||||
it('ThenDeleteButtonIsDisabledWhenNameDoesNotMatch', async () => {
|
||||
const wrapper = mountDetail();
|
||||
|
||||
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const deleteBtn = wrapper.findComponent('[data-testid="delete-feature-btn"]') as VueWrapper<any>;
|
||||
expect(deleteBtn.props('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenDeleteFeatureIsCalledWhenNameMatches', async () => {
|
||||
jest.mocked(featuresApi.deleteFeature).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
|
||||
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Type the feature name into the confirm input
|
||||
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', mockFeature.name);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="delete-feature-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(featuresApi.deleteFeature).toHaveBeenCalledWith(mockProject.id, mockFeature.id);
|
||||
});
|
||||
|
||||
it('ThenDeletedEventIsEmittedAfterDeletion', async () => {
|
||||
jest.mocked(featuresApi.deleteFeature).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
|
||||
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', mockFeature.name);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="delete-feature-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('deleted')).toBeTruthy();
|
||||
expect(wrapper.emitted('deleted')![0]).toEqual([mockFeature.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
133
src/admin/tests/unit/components/features/FeatureDialog.spec.ts
Normal file
133
src/admin/tests/unit/components/features/FeatureDialog.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import FeatureDialog from '@/components/features/FeatureDialog.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { FeatureResponse, ProjectResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/features');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as featuresApi from '@/api/features';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockFeature: FeatureResponse = {
|
||||
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null,
|
||||
description: 'Enables dark mode', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const savedFeature: FeatureResponse = { ...mockFeature, id: 99, name: 'new_flag' };
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(FeatureDialog, {
|
||||
props: { modelValue: true, ...props },
|
||||
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('FeatureDialog', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
});
|
||||
|
||||
describe('WhenOpenedInCreateMode', () => {
|
||||
it('ThenNameTypeAndDescriptionFieldsArePresent', () => {
|
||||
const wrapper = mountDialog();
|
||||
expect(wrapper.find('[data-testid="feature-name-input"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="feature-type-select"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="feature-description-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenNameAndTypeFieldsAreEnabled', () => {
|
||||
const wrapper = mountDialog();
|
||||
const nameInput = wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>;
|
||||
expect(nameInput.props('disabled')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOpenedInEditMode', () => {
|
||||
it('ThenNameAndTypeFieldsAreDisabled', () => {
|
||||
const wrapper = mountDialog({ feature: mockFeature });
|
||||
const nameInput = wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>;
|
||||
expect(nameInput.props('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenFormIsPopulatedWithFeatureData', () => {
|
||||
const wrapper = mountDialog({ feature: mockFeature });
|
||||
const descInput = wrapper.findComponent('[data-testid="feature-description-input"]') as VueWrapper<any>;
|
||||
expect(descInput.props('modelValue')).toBe(mockFeature.description);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenFormIsSubmittedInCreateMode', () => {
|
||||
it('ThenCreateFeatureApiIsCalled', async () => {
|
||||
jest.mocked(featuresApi.createFeature).mockResolvedValue(savedFeature);
|
||||
|
||||
const wrapper = mountDialog();
|
||||
|
||||
// Fill in name
|
||||
await (wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_flag');
|
||||
|
||||
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(featuresApi.createFeature).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ name: 'new_flag', type: 'STANDARD' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ThenSavedEventIsEmitted', async () => {
|
||||
jest.mocked(featuresApi.createFeature).mockResolvedValue(savedFeature);
|
||||
|
||||
const wrapper = mountDialog();
|
||||
await (wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_flag');
|
||||
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||||
expect(wrapper.emitted('saved')![0]).toEqual([savedFeature]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenFormIsSubmittedInEditMode', () => {
|
||||
it('ThenUpdateFeatureApiIsCalled', async () => {
|
||||
jest.mocked(featuresApi.updateFeature).mockResolvedValue({ ...mockFeature, description: 'Updated' });
|
||||
|
||||
const wrapper = mountDialog({ feature: mockFeature });
|
||||
await (wrapper.findComponent('[data-testid="feature-description-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Updated');
|
||||
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(featuresApi.updateFeature).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
mockFeature.id,
|
||||
expect.objectContaining({ name: mockFeature.name }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCancelIsClicked', () => {
|
||||
it('ThenDialogIsClosedWithoutApiCall', async () => {
|
||||
const wrapper = mountDialog();
|
||||
await wrapper.find('[data-testid="feature-dialog-cancel"]').trigger('click');
|
||||
|
||||
expect(featuresApi.createFeature).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy();
|
||||
expect(wrapper.emitted('update:modelValue')![0]).toEqual([false]);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
src/admin/tests/unit/components/features/TagsChipInput.spec.ts
Normal file
139
src/admin/tests/unit/components/features/TagsChipInput.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import TagsChipInput from '@/components/features/TagsChipInput.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { ProjectResponse, TagResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/tags');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as tagsApi from '@/api/tags';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockTags: TagResponse[] = [
|
||||
{ id: 1, label: 'backend', color: '#ff0000', projectId: 10 },
|
||||
{ id: 2, label: 'frontend', color: '#0000ff', projectId: 10 },
|
||||
];
|
||||
|
||||
function mountComponent() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(TagsChipInput, { global: { plugins: [router] } });
|
||||
}
|
||||
|
||||
describe('TagsChipInput', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
});
|
||||
|
||||
describe('WhenComponentMounts', () => {
|
||||
it('ThenTagsAreFetchedFromApi', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
mountComponent();
|
||||
await flushPromises();
|
||||
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
|
||||
});
|
||||
|
||||
it('ThenExistingTagChipsAreRendered', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenEmptyMessageIsShownWhenNoTagsExist', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateTagIsSubmitted', () => {
|
||||
it('ThenCreateTagApiIsCalled', async () => {
|
||||
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
// Open the create form
|
||||
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Fill in the label
|
||||
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
|
||||
|
||||
// Submit
|
||||
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(tagsApi.createTag).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ label: 'api' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ThenNewTagAppearsInTheList', async () => {
|
||||
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
|
||||
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
|
||||
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tag-chip-api"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenTagChipIsDeleted', () => {
|
||||
it('ThenDeleteTagApiIsCalled', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
|
||||
await chip.vm.$emit('click:close');
|
||||
await flushPromises();
|
||||
|
||||
expect(tagsApi.deleteTag).toHaveBeenCalledWith(mockProject.id, mockTags[0].id);
|
||||
});
|
||||
|
||||
it('ThenDeletedTagIsRemovedFromTheList', async () => {
|
||||
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
|
||||
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
|
||||
await chip.vm.$emit('click:close');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
src/admin/tests/unit/views/FeaturesView.spec.ts
Normal file
132
src/admin/tests/unit/views/FeaturesView.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import FeaturesView from '@/views/FeaturesView.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/features');
|
||||
jest.mock('@/api/featureStates');
|
||||
jest.mock('@/api/tags');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as featuresApi from '@/api/features';
|
||||
import * as featureStatesApi from '@/api/featureStates';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockEnv: EnvironmentResponse = {
|
||||
id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockFeatures: FeatureResponse[] = [
|
||||
{ id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
const mockStates: FeatureStateResponse[] = [
|
||||
{ id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null, enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 102, featureId: 2, environmentId: 100, identityId: null, featureSegmentId: null, enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
function mountView() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(FeaturesView, { global: { plugins: [router] } });
|
||||
}
|
||||
|
||||
describe('FeaturesView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenNoProjectIsSelected', () => {
|
||||
it('ThenEmptyStateIsShown', () => {
|
||||
const wrapper = mountView();
|
||||
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('Select a project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectIsSelected', () => {
|
||||
it('ThenFeaturesAndStatesAreLoaded', async () => {
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
|
||||
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(featuresApi.listFeatures).toHaveBeenCalledWith(mockProject.id, 1, 100);
|
||||
expect(featureStatesApi.listFeatureStates).toHaveBeenCalledWith(mockEnv.apiKey);
|
||||
});
|
||||
|
||||
it('ThenFeaturesTableIsRendered', async () => {
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
|
||||
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="create-feature-btn"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenToggleEnabledIsClicked', () => {
|
||||
it('ThenPatchFeatureStateIsCalled', async () => {
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
|
||||
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
|
||||
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue({ ...mockStates[0], enabled: true });
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// Find the toggle for feature id=1 and simulate a change
|
||||
const toggle = wrapper.find('[data-testid="toggle-1"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
await toggle.findComponent({ name: 'VSwitch' }).vm.$emit('update:modelValue', true);
|
||||
await flushPromises();
|
||||
|
||||
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith(mockEnv.apiKey, 101, { enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateButtonIsClicked', () => {
|
||||
it('ThenFeatureDialogIsOpened', async () => {
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 0, next: null, previous: null, results: [] });
|
||||
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-feature-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// FeatureDialog should now be open (v-dialog with model-value=true)
|
||||
const dialog = wrapper.findComponent({ name: 'FeatureDialog' });
|
||||
expect(dialog.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user