From 8dfcb107d07c4d435c501bc4172a7f0b59ffb76e Mon Sep 17 00:00:00 2001 From: James Wampler Date: Mon, 13 Apr 2026 16:04:23 -0700 Subject: [PATCH] Adding Feature flag management --- src/admin/src/api/featureStates.ts | 44 +++ src/admin/src/api/features.ts | 66 ++++ src/admin/src/api/tags.ts | 16 + .../src/components/features/FeatureDetail.vue | 370 ++++++++++++++++++ .../src/components/features/FeatureDialog.vue | 217 ++++++++++ .../features/FeatureValueEditor.vue | 86 ++++ .../src/components/features/TagsChipInput.vue | 175 +++++++++ src/admin/src/views/FeaturesView.vue | 324 ++++++++++++++- src/admin/tests/setup.ts | 18 + .../components/features/FeatureDetail.spec.ts | 238 +++++++++++ .../components/features/FeatureDialog.spec.ts | 133 +++++++ .../components/features/TagsChipInput.spec.ts | 139 +++++++ .../tests/unit/views/FeaturesView.spec.ts | 132 +++++++ 13 files changed, 1946 insertions(+), 12 deletions(-) create mode 100644 src/admin/src/api/featureStates.ts create mode 100644 src/admin/src/api/features.ts create mode 100644 src/admin/src/api/tags.ts create mode 100644 src/admin/src/components/features/FeatureDetail.vue create mode 100644 src/admin/src/components/features/FeatureDialog.vue create mode 100644 src/admin/src/components/features/FeatureValueEditor.vue create mode 100644 src/admin/src/components/features/TagsChipInput.vue create mode 100644 src/admin/tests/unit/components/features/FeatureDetail.spec.ts create mode 100644 src/admin/tests/unit/components/features/FeatureDialog.spec.ts create mode 100644 src/admin/tests/unit/components/features/TagsChipInput.spec.ts create mode 100644 src/admin/tests/unit/views/FeaturesView.spec.ts diff --git a/src/admin/src/api/featureStates.ts b/src/admin/src/api/featureStates.ts new file mode 100644 index 0000000..2c8e122 --- /dev/null +++ b/src/admin/src/api/featureStates.ts @@ -0,0 +1,44 @@ +import apiClient from './client'; +import type { + FeatureStateResponse, + UpdateFeatureStateRequest, + PatchFeatureStateRequest, +} from '@/types/api'; + +export async function listFeatureStates(envApiKey: string): Promise { + const { data } = await apiClient.get( + `/v1/environments/${envApiKey}/featurestates`, + ); + return data; +} + +export async function getFeatureState(envApiKey: string, id: number): Promise { + const { data } = await apiClient.get( + `/v1/environments/${envApiKey}/featurestates/${id}`, + ); + return data; +} + +export async function updateFeatureState( + envApiKey: string, + id: number, + request: UpdateFeatureStateRequest, +): Promise { + const { data } = await apiClient.put( + `/v1/environments/${envApiKey}/featurestates/${id}`, + request, + ); + return data; +} + +export async function patchFeatureState( + envApiKey: string, + id: number, + request: PatchFeatureStateRequest, +): Promise { + const { data } = await apiClient.patch( + `/v1/environments/${envApiKey}/featurestates/${id}`, + request, + ); + return data; +} diff --git a/src/admin/src/api/features.ts b/src/admin/src/api/features.ts new file mode 100644 index 0000000..d013ac7 --- /dev/null +++ b/src/admin/src/api/features.ts @@ -0,0 +1,66 @@ +import apiClient from './client'; +import type { + FeatureResponse, + CreateFeatureRequest, + UpdateFeatureRequest, + PatchFeatureRequest, + PaginatedResponse, +} from '@/types/api'; + +export async function listFeatures( + projectId: number, + page = 1, + pageSize = 100, +): Promise> { + const { data } = await apiClient.get>( + `/v1/projects/${projectId}/features`, + { params: { page, pageSize } }, + ); + return data; +} + +export async function getFeature(projectId: number, id: number): Promise { + const { data } = await apiClient.get( + `/v1/projects/${projectId}/features/${id}`, + ); + return data; +} + +export async function createFeature( + projectId: number, + request: CreateFeatureRequest, +): Promise { + const { data } = await apiClient.post( + `/v1/projects/${projectId}/features`, + request, + ); + return data; +} + +export async function updateFeature( + projectId: number, + id: number, + request: UpdateFeatureRequest, +): Promise { + const { data } = await apiClient.put( + `/v1/projects/${projectId}/features/${id}`, + request, + ); + return data; +} + +export async function patchFeature( + projectId: number, + id: number, + request: PatchFeatureRequest, +): Promise { + const { data } = await apiClient.patch( + `/v1/projects/${projectId}/features/${id}`, + request, + ); + return data; +} + +export async function deleteFeature(projectId: number, id: number): Promise { + await apiClient.delete(`/v1/projects/${projectId}/features/${id}`); +} diff --git a/src/admin/src/api/tags.ts b/src/admin/src/api/tags.ts new file mode 100644 index 0000000..ea704ae --- /dev/null +++ b/src/admin/src/api/tags.ts @@ -0,0 +1,16 @@ +import apiClient from './client'; +import type { TagResponse, CreateTagRequest } from '@/types/api'; + +export async function listTags(projectId: number): Promise { + const { data } = await apiClient.get(`/v1/projects/${projectId}/tags`); + return data; +} + +export async function createTag(projectId: number, request: CreateTagRequest): Promise { + const { data } = await apiClient.post(`/v1/projects/${projectId}/tags`, request); + return data; +} + +export async function deleteTag(projectId: number, id: number): Promise { + await apiClient.delete(`/v1/projects/${projectId}/tags/${id}`); +} diff --git a/src/admin/src/components/features/FeatureDetail.vue b/src/admin/src/components/features/FeatureDetail.vue new file mode 100644 index 0000000..8a95687 --- /dev/null +++ b/src/admin/src/components/features/FeatureDetail.vue @@ -0,0 +1,370 @@ + + + diff --git a/src/admin/src/components/features/FeatureDialog.vue b/src/admin/src/components/features/FeatureDialog.vue new file mode 100644 index 0000000..97eaa67 --- /dev/null +++ b/src/admin/src/components/features/FeatureDialog.vue @@ -0,0 +1,217 @@ + + + diff --git a/src/admin/src/components/features/FeatureValueEditor.vue b/src/admin/src/components/features/FeatureValueEditor.vue new file mode 100644 index 0000000..112cea6 --- /dev/null +++ b/src/admin/src/components/features/FeatureValueEditor.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/admin/src/components/features/TagsChipInput.vue b/src/admin/src/components/features/TagsChipInput.vue new file mode 100644 index 0000000..038edf2 --- /dev/null +++ b/src/admin/src/components/features/TagsChipInput.vue @@ -0,0 +1,175 @@ + + + diff --git a/src/admin/src/views/FeaturesView.vue b/src/admin/src/views/FeaturesView.vue index 6957724..7c2dfb9 100644 --- a/src/admin/src/views/FeaturesView.vue +++ b/src/admin/src/views/FeaturesView.vue @@ -1,17 +1,317 @@ - diff --git a/src/admin/tests/setup.ts b/src/admin/tests/setup.ts index 02cfc24..51550bb 100644 --- a/src/admin/tests/setup.ts +++ b/src/admin/tests/setup.ts @@ -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 }, diff --git a/src/admin/tests/unit/components/features/FeatureDetail.spec.ts b/src/admin/tests/unit/components/features/FeatureDetail.spec.ts new file mode 100644 index 0000000..e7c917d --- /dev/null +++ b/src/admin/tests/unit/components/features/FeatureDetail.spec.ts @@ -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: '
' }; +const tagsStub = { template: '
' }; + +function mountDetail(props: Record = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + 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).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).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).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).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).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).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; + 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).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).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]); + }); + }); +}); diff --git a/src/admin/tests/unit/components/features/FeatureDialog.spec.ts b/src/admin/tests/unit/components/features/FeatureDialog.spec.ts new file mode 100644 index 0000000..2305690 --- /dev/null +++ b/src/admin/tests/unit/components/features/FeatureDialog.spec.ts @@ -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: '
' }; + +function mountDialog(props: Record = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + 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; + 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; + expect(nameInput.props('disabled')).toBe(true); + }); + + it('ThenFormIsPopulatedWithFeatureData', () => { + const wrapper = mountDialog({ feature: mockFeature }); + const descInput = wrapper.findComponent('[data-testid="feature-description-input"]') as VueWrapper; + 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).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).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).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]); + }); + }); +}); diff --git a/src/admin/tests/unit/components/features/TagsChipInput.spec.ts b/src/admin/tests/unit/components/features/TagsChipInput.spec.ts new file mode 100644 index 0000000..692d9df --- /dev/null +++ b/src/admin/tests/unit/components/features/TagsChipInput.spec.ts @@ -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: '
' } }] }); + 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).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).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; + 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; + await chip.vm.$emit('click:close'); + await flushPromises(); + + expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(false); + }); + }); +}); diff --git a/src/admin/tests/unit/views/FeaturesView.spec.ts b/src/admin/tests/unit/views/FeaturesView.spec.ts new file mode 100644 index 0000000..246e000 --- /dev/null +++ b/src/admin/tests/unit/views/FeaturesView.spec.ts @@ -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: '
' } }] }); + 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); + }); + }); +});