diff --git a/src/admin/src/api/identities.ts b/src/admin/src/api/identities.ts new file mode 100644 index 0000000..a8b46d8 --- /dev/null +++ b/src/admin/src/api/identities.ts @@ -0,0 +1,63 @@ +import apiClient from './client'; +import type { + IdentityResponse, + FeatureStateResponse, + UpdateFeatureStateRequest, + PaginatedResponse, +} from '@/types/api'; + +export async function listIdentities( + envApiKey: string, + page = 1, + pageSize = 100, +): Promise> { + const { data } = await apiClient.get>( + `/v1/environments/${envApiKey}/identities`, + { params: { page, pageSize } }, + ); + return data; +} + +export async function getIdentity(envApiKey: string, id: number): Promise { + const { data } = await apiClient.get( + `/v1/environments/${envApiKey}/identities/${id}`, + ); + return data; +} + +export async function deleteIdentity(envApiKey: string, id: number): Promise { + await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`); +} + +export async function getIdentityFeatureStates( + envApiKey: string, + identityId: number, +): Promise { + const { data } = await apiClient.get( + `/v1/environments/${envApiKey}/identities/${identityId}/featurestates`, + ); + return data; +} + +export async function setIdentityFeatureState( + envApiKey: string, + identityId: number, + featureId: number, + request: UpdateFeatureStateRequest, +): Promise { + const { data } = await apiClient.put( + `/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`, + request, + ); + return data; +} + +export async function deleteIdentityFeatureState( + envApiKey: string, + identityId: number, + featureId: number, +): Promise { + await apiClient.delete( + `/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`, + ); +} diff --git a/src/admin/src/components/identities/IdentityDetail.vue b/src/admin/src/components/identities/IdentityDetail.vue new file mode 100644 index 0000000..67a60e5 --- /dev/null +++ b/src/admin/src/components/identities/IdentityDetail.vue @@ -0,0 +1,374 @@ + + + diff --git a/src/admin/src/views/IdentitiesView.vue b/src/admin/src/views/IdentitiesView.vue index 3e9b612..acc8feb 100644 --- a/src/admin/src/views/IdentitiesView.vue +++ b/src/admin/src/views/IdentitiesView.vue @@ -1,18 +1,175 @@ diff --git a/src/admin/tests/unit/components/identities/IdentityDetail.spec.ts b/src/admin/tests/unit/components/identities/IdentityDetail.spec.ts new file mode 100644 index 0000000..2cdb503 --- /dev/null +++ b/src/admin/tests/unit/components/identities/IdentityDetail.spec.ts @@ -0,0 +1,266 @@ +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 IdentityDetail from '@/components/identities/IdentityDetail.vue'; +import { useContextStore } from '@/stores/context'; +import type { EnvironmentResponse, IdentityResponse, FeatureResponse, FeatureStateResponse, ProjectResponse } from '@/types/api'; + +jest.mock('@/api/identities'); +jest.mock('@/api/features'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as identitiesApi from '@/api/identities'; +import * as featuresApi from '@/api/features'; + +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 mockIdentity: IdentityResponse = { + id: 1, + identifier: 'user-alice', + environmentId: 100, + traits: [{ key: 'plan', value: 'pro' }, { key: 'country', value: 'US' }], + createdAt: '2026-01-01T00:00:00Z', +}; +const mockFeatures: FeatureResponse[] = [ + { id: 10, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' }, + { id: 20, name: 'beta_feature', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' }, +]; +const mockOverride: FeatureStateResponse = { + id: 500, featureId: 10, environmentId: 100, identityId: 1, featureSegmentId: null, + enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', +}; + +const dialogStub = { template: '
' }; + +function mountDetail(props: Record = {}) { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(IdentityDetail, { + props: { modelValue: true, identity: mockIdentity, ...props }, + global: { plugins: [router], stubs: { 'v-dialog': dialogStub } }, + }); +} + +describe('IdentityDetail', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + contextStore.setEnvironment(mockEnv); + + jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([]); + jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures }); + }); + + describe('WhenOpenedWithIdentity', () => { + it('ThenDetailCardIsRendered', async () => { + const wrapper = mountDetail(); + await flushPromises(); + expect(wrapper.find('[data-testid="identity-detail"]').exists()).toBe(true); + }); + + it('ThenIdentifierIsDisplayed', async () => { + const wrapper = mountDetail(); + await flushPromises(); + expect(wrapper.text()).toContain('user-alice'); + }); + + it('ThenTraitsAndOverridesTabsArePresent', async () => { + const wrapper = mountDetail(); + await flushPromises(); + expect(wrapper.find('[data-testid="tab-traits"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="tab-overrides"]').exists()).toBe(true); + }); + }); + + describe('WhenTraitsTabIsActive', () => { + it('ThenTraitsTableIsRendered', async () => { + const wrapper = mountDetail(); + await flushPromises(); + expect(wrapper.find('[data-testid="traits-table"]').exists()).toBe(true); + }); + + it('ThenTraitRowsAreDisplayed', async () => { + const wrapper = mountDetail(); + await flushPromises(); + expect(wrapper.find('[data-testid="trait-row-plan"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="trait-row-country"]').exists()).toBe(true); + expect(wrapper.text()).toContain('plan'); + expect(wrapper.text()).toContain('pro'); + }); + + it('ThenEmptyMessageIsShownWhenNoTraits', async () => { + const identityWithNoTraits: IdentityResponse = { ...mockIdentity, traits: [] }; + const wrapper = mountDetail({ identity: identityWithNoTraits }); + await flushPromises(); + expect(wrapper.find('[data-testid="traits-table"]').exists()).toBe(false); + }); + }); + + describe('WhenDeleteIdentityIsClicked', () => { + it('ThenDeleteIdentityApiIsCalled', async () => { + jest.mocked(identitiesApi.deleteIdentity).mockResolvedValue(undefined); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="delete-identity-btn"]').trigger('click'); + await flushPromises(); + + expect(identitiesApi.deleteIdentity).toHaveBeenCalledWith(mockEnv.apiKey, mockIdentity.id); + }); + + it('ThenDeletedEventIsEmitted', async () => { + jest.mocked(identitiesApi.deleteIdentity).mockResolvedValue(undefined); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="delete-identity-btn"]').trigger('click'); + await flushPromises(); + + expect(wrapper.emitted('deleted')).toBeTruthy(); + expect(wrapper.emitted('deleted')![0]).toEqual([mockIdentity.id]); + }); + }); + + describe('WhenOverridesTabIsActive', () => { + it('ThenNoOverridesMessageIsShownWhenEmpty', async () => { + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-overrides"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="no-overrides-message"]').exists()).toBe(true); + }); + + it('ThenExistingOverridesAreDisplayed', async () => { + jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-overrides"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="overrides-table"]').exists()).toBe(true); + expect(wrapper.find(`[data-testid="override-row-${mockOverride.featureId}"]`).exists()).toBe(true); + }); + + it('ThenFeatureNameIsShownInOverrideRow', async () => { + jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-overrides"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('dark_mode'); + }); + }); + + describe('WhenOverrideToggleIsChanged', () => { + it('ThenSetIdentityFeatureStateIsCalledWithNewEnabled', async () => { + jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]); + jest.mocked(identitiesApi.setIdentityFeatureState).mockResolvedValue({ ...mockOverride, enabled: false }); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-overrides"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent(`[data-testid="override-toggle-${mockOverride.featureId}"]`) as VueWrapper).vm.$emit('update:modelValue', false); + await flushPromises(); + + expect(identitiesApi.setIdentityFeatureState).toHaveBeenCalledWith( + mockEnv.apiKey, + mockIdentity.id, + mockOverride.featureId, + { enabled: false, value: null }, + ); + }); + }); + + describe('WhenRemoveOverrideIsClicked', () => { + it('ThenDeleteIdentityFeatureStateIsCalledAndOverrideIsRemoved', async () => { + jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]); + jest.mocked(identitiesApi.deleteIdentityFeatureState).mockResolvedValue(undefined); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-overrides"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find(`[data-testid="remove-override-${mockOverride.featureId}"]`).trigger('click'); + await flushPromises(); + + expect(identitiesApi.deleteIdentityFeatureState).toHaveBeenCalledWith( + mockEnv.apiKey, + mockIdentity.id, + mockOverride.featureId, + ); + expect(wrapper.find('[data-testid="overrides-table"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="no-overrides-message"]').exists()).toBe(true); + }); + }); + + describe('WhenAddOverrideIsSubmitted', () => { + it('ThenSetIdentityFeatureStateIsCalledWithSelectedFeature', async () => { + const newOverride: FeatureStateResponse = { ...mockOverride, featureId: 20, enabled: true }; + jest.mocked(identitiesApi.setIdentityFeatureState).mockResolvedValue(newOverride); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-overrides"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="add-override-feature-select"]') as VueWrapper).vm.$emit('update:modelValue', 20); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="add-override-btn"]').trigger('click'); + await flushPromises(); + + expect(identitiesApi.setIdentityFeatureState).toHaveBeenCalledWith( + mockEnv.apiKey, + mockIdentity.id, + 20, + expect.objectContaining({ enabled: false }), + ); + }); + + it('ThenOverrideIsAddedToList', async () => { + const newOverride: FeatureStateResponse = { ...mockOverride, featureId: 20, enabled: true }; + jest.mocked(identitiesApi.setIdentityFeatureState).mockResolvedValue(newOverride); + + const wrapper = mountDetail(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-overrides"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="add-override-feature-select"]') as VueWrapper).vm.$emit('update:modelValue', 20); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="add-override-btn"]').trigger('click'); + await flushPromises(); + + expect(wrapper.find('[data-testid="overrides-table"]').exists()).toBe(true); + expect(wrapper.find(`[data-testid="override-row-20"]`).exists()).toBe(true); + }); + }); +}); diff --git a/src/admin/tests/unit/views/IdentitiesView.spec.ts b/src/admin/tests/unit/views/IdentitiesView.spec.ts new file mode 100644 index 0000000..4e41a1e --- /dev/null +++ b/src/admin/tests/unit/views/IdentitiesView.spec.ts @@ -0,0 +1,180 @@ +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 IdentitiesView from '@/views/IdentitiesView.vue'; +import { useContextStore } from '@/stores/context'; +import type { ProjectResponse, EnvironmentResponse, IdentityResponse } from '@/types/api'; + +jest.mock('@/api/identities'); +jest.mock('@/api/features'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as identitiesApi from '@/api/identities'; + +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 mockIdentities: IdentityResponse[] = [ + { id: 1, identifier: 'user-alice', environmentId: 100, traits: [{ key: 'plan', value: 'pro' }], createdAt: '2026-01-01T00:00:00Z' }, + { id: 2, identifier: 'user-bob', environmentId: 100, traits: [], createdAt: '2026-01-02T00:00:00Z' }, +]; + +const dialogStub = { template: '
' }; + +function mountView() { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(IdentitiesView, { + global: { plugins: [router], stubs: { 'v-dialog': dialogStub } }, + }); +} + +describe('IdentitiesView', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + }); + + describe('WhenNoEnvironmentIsSelected', () => { + it('ThenEmptyStateIsShown', () => { + const wrapper = mountView(); + expect(wrapper.find('[data-testid="identities-table"]').exists()).toBe(false); + expect(wrapper.text()).toContain('Select an environment'); + }); + }); + + describe('WhenEnvironmentIsSelected', () => { + it('ThenIdentitiesAreLoaded', async () => { + jest.mocked(identitiesApi.listIdentities).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockIdentities, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + contextStore.setEnvironment(mockEnv); + + mountView(); + await flushPromises(); + + expect(identitiesApi.listIdentities).toHaveBeenCalledWith(mockEnv.apiKey, 1, 100); + }); + + it('ThenIdentitiesTableIsRendered', async () => { + jest.mocked(identitiesApi.listIdentities).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockIdentities, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + contextStore.setEnvironment(mockEnv); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="identities-table"]').exists()).toBe(true); + expect(wrapper.text()).toContain('user-alice'); + expect(wrapper.text()).toContain('user-bob'); + }); + + it('ThenSearchFilterIsApplied', async () => { + jest.mocked(identitiesApi.listIdentities).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockIdentities, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + contextStore.setEnvironment(mockEnv); + + const wrapper = mountView(); + await flushPromises(); + + // Set search value + const searchInput = wrapper.find('[data-testid="identity-search"]'); + await searchInput.find('input').setValue('alice'); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('user-alice'); + expect(wrapper.text()).not.toContain('user-bob'); + }); + }); + + describe('WhenViewButtonIsClicked', () => { + it('ThenIdentityDetailIsOpened', async () => { + jest.mocked(identitiesApi.listIdentities).mockResolvedValue({ + count: 2, next: null, previous: null, results: mockIdentities, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + contextStore.setEnvironment(mockEnv); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="view-identity-1"]').trigger('click'); + await wrapper.vm.$nextTick(); + + const detail = wrapper.findComponent({ name: 'IdentityDetail' }); + expect(detail.props('identity')).toEqual(mockIdentities[0]); + }); + }); + + describe('WhenIdentityIsDeleted', () => { + it('ThenIdentityIsRemovedFromList', async () => { + jest.mocked(identitiesApi.listIdentities).mockResolvedValue({ + count: 2, next: null, previous: null, results: [...mockIdentities], + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + contextStore.setEnvironment(mockEnv); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.findComponent({ name: 'IdentityDetail' }).vm.$emit('deleted', 1); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain('user-alice'); + expect(wrapper.text()).toContain('user-bob'); + }); + }); + + describe('WhenEnvironmentChanges', () => { + it('ThenIdentitiesAreReloaded', async () => { + const mockEnv2: EnvironmentResponse = { + id: 200, name: 'Production', apiKey: 'env-prod', projectId: 10, createdAt: '2026-01-01T00:00:00Z', + }; + + jest.mocked(identitiesApi.listIdentities).mockResolvedValue({ + count: 0, next: null, previous: null, results: [], + }); + + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' }); + contextStore.setProject(mockProject); + contextStore.setEnvironment(mockEnv); + + mountView(); + await flushPromises(); + + contextStore.setEnvironment(mockEnv2); + await flushPromises(); + + expect(identitiesApi.listIdentities).toHaveBeenCalledTimes(2); + expect(identitiesApi.listIdentities).toHaveBeenLastCalledWith(mockEnv2.apiKey, 1, 100); + }); + }); +});