Adding Identities

This commit is contained in:
2026-04-13 19:37:56 -07:00
parent 3fff7e3158
commit fc62ea634a
5 changed files with 1052 additions and 12 deletions

View File

@@ -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<PaginatedResponse<IdentityResponse>> {
const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>(
`/v1/environments/${envApiKey}/identities`,
{ params: { page, pageSize } },
);
return data;
}
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
const { data } = await apiClient.get<IdentityResponse>(
`/v1/environments/${envApiKey}/identities/${id}`,
);
return data;
}
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
}
export async function getIdentityFeatureStates(
envApiKey: string,
identityId: number,
): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates`,
);
return data;
}
export async function setIdentityFeatureState(
envApiKey: string,
identityId: number,
featureId: number,
request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
request,
);
return data;
}
export async function deleteIdentityFeatureState(
envApiKey: string,
identityId: number,
featureId: number,
): Promise<void> {
await apiClient.delete(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
);
}

View File

@@ -0,0 +1,374 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="680"
scrollable
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card v-if="identity" rounded="lg" data-testid="identity-detail">
<!-- Header -->
<v-card-title class="d-flex align-center justify-space-between pa-6 pb-3">
<div>
<p class="text-h6 mb-0">{{ identity.identifier }}</p>
<p class="text-caption text-medium-emphasis mb-0">ID: {{ identity.id }}</p>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="$emit('update:modelValue', false)" />
</v-card-title>
<v-tabs v-model="activeTab" color="primary" class="px-4">
<v-tab value="traits" data-testid="tab-traits">Traits</v-tab>
<v-tab value="overrides" data-testid="tab-overrides">Feature Overrides</v-tab>
</v-tabs>
<v-divider />
<v-card-text class="pa-0">
<v-tabs-window v-model="activeTab">
<!-- Traits tab -->
<v-tabs-window-item value="traits" class="pa-6">
<v-alert
v-if="traitsError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="traitsError = null"
>
{{ traitsError }}
</v-alert>
<div v-if="identity.traits.length === 0" class="text-center py-6 text-medium-emphasis">
<v-icon size="32" class="mb-2">mdi-tag-outline</v-icon>
<p class="text-body-2">No traits recorded for this identity.</p>
</div>
<v-table v-else density="compact" data-testid="traits-table">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-for="trait in identity.traits" :key="trait.key" :data-testid="`trait-row-${trait.key}`">
<td class="text-body-2 font-weight-medium">{{ trait.key }}</td>
<td class="text-body-2">{{ trait.value }}</td>
</tr>
</tbody>
</v-table>
<!-- Danger zone -->
<v-card variant="outlined" color="error" rounded="lg" class="mt-6">
<v-card-title class="text-body-1 text-error pa-4 pb-2">Danger Zone</v-card-title>
<v-card-text class="pa-4 pt-0">
<p class="text-body-2 mb-3">
Permanently delete this identity and all its traits and feature overrides.
This cannot be undone.
</p>
<v-btn
color="error"
variant="flat"
size="small"
:loading="isDeleting"
data-testid="delete-identity-btn"
@click="onDeleteIdentity"
>
Delete identity
</v-btn>
</v-card-text>
</v-card>
</v-tabs-window-item>
<!-- Overrides tab -->
<v-tabs-window-item value="overrides" class="pa-6">
<v-alert
v-if="overridesError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="overridesError = null"
>
{{ overridesError }}
</v-alert>
<div v-if="isLoadingOverrides" class="d-flex justify-center py-6">
<v-progress-circular indeterminate color="primary" />
</div>
<template v-else>
<!-- Existing overrides -->
<div v-if="overrides.length === 0" class="text-center py-4 text-medium-emphasis text-body-2 mb-4" data-testid="no-overrides-message">
No feature overrides for this identity.
</div>
<v-table v-else density="compact" class="mb-4" data-testid="overrides-table">
<thead>
<tr>
<th>Feature</th>
<th style="width: 80px">Enabled</th>
<th>Value</th>
<th style="width: 40px"></th>
</tr>
</thead>
<tbody>
<tr v-for="override in overrides" :key="override.id" :data-testid="`override-row-${override.featureId}`">
<td class="text-body-2 font-weight-medium">{{ featureNameMap.get(override.featureId) ?? `Feature ${override.featureId}` }}</td>
<td>
<v-switch
:model-value="override.enabled"
color="primary"
hide-details
density="compact"
:data-testid="`override-toggle-${override.featureId}`"
@update:model-value="onToggleOverride(override, $event)"
/>
</td>
<td>
<v-text-field
:model-value="override.value ?? ''"
variant="underlined"
density="compact"
hide-details
placeholder="(none)"
:data-testid="`override-value-${override.featureId}`"
@update:model-value="onUpdateOverrideValue(override, $event)"
/>
</td>
<td>
<v-btn
icon="mdi-trash-can-outline"
size="x-small"
variant="text"
color="error"
:data-testid="`remove-override-${override.featureId}`"
@click="onRemoveOverride(override)"
/>
</td>
</tr>
</tbody>
</v-table>
<!-- Add Override -->
<v-divider class="mb-4" />
<p class="text-body-2 font-weight-medium mb-3">Add Override</p>
<div class="d-flex align-center ga-3 flex-wrap">
<v-select
v-model="newOverrideFeatureId"
:items="availableFeatures"
item-title="name"
item-value="id"
label="Select feature"
variant="outlined"
density="compact"
hide-details
style="min-width: 200px; flex: 2"
data-testid="add-override-feature-select"
/>
<v-switch
v-model="newOverrideEnabled"
color="primary"
label="Enabled"
hide-details
density="compact"
data-testid="add-override-enabled-toggle"
/>
<v-text-field
v-model="newOverrideValue"
label="Value (optional)"
variant="outlined"
density="compact"
hide-details
style="min-width: 140px; flex: 1"
data-testid="add-override-value-input"
/>
<v-btn
color="primary"
variant="flat"
size="small"
:disabled="!newOverrideFeatureId"
:loading="isAddingOverride"
data-testid="add-override-btn"
@click="onAddOverride"
>
Add
</v-btn>
</div>
</template>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { getIdentityFeatureStates, setIdentityFeatureState, deleteIdentity, deleteIdentityFeatureState } from '@/api/identities';
import { listFeatures } from '@/api/features';
import { useContextStore } from '@/stores/context';
import type { IdentityResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
interface Props {
modelValue: boolean;
identity: IdentityResponse | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
deleted: [identityId: number];
}>();
const contextStore = useContextStore();
const activeTab = ref('traits');
const isDeleting = ref(false);
const traitsError = ref<string | null>(null);
const overrides = ref<FeatureStateResponse[]>([]);
const allFeatures = ref<FeatureResponse[]>([]);
const isLoadingOverrides = ref(false);
const overridesError = ref<string | null>(null);
const isAddingOverride = ref(false);
const newOverrideFeatureId = ref<number | null>(null);
const newOverrideEnabled = ref(false);
const newOverrideValue = ref('');
// Map featureId → name for display in overrides table
const featureNameMap = computed(() => new Map(allFeatures.value.map((f) => [f.id, f.name])));
// Only show features that don't already have an override
const availableFeatures = computed(() => {
const overriddenIds = new Set(overrides.value.map((o) => o.featureId));
return allFeatures.value.filter((f) => !overriddenIds.has(f.id));
});
async function loadOverridesData(): Promise<void> {
if (!props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
const projectId = contextStore.currentProject?.id;
if (!envApiKey || !projectId) return;
isLoadingOverrides.value = true;
overridesError.value = null;
try {
const [states, features] = await Promise.all([
getIdentityFeatureStates(envApiKey, props.identity.id),
allFeatures.value.length === 0 ? listFeatures(projectId, 1, 100) : Promise.resolve(null),
]);
overrides.value = states;
if (features) allFeatures.value = features.results;
} catch {
overridesError.value = 'Failed to load feature overrides. Please try again.';
} finally {
isLoadingOverrides.value = false;
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
activeTab.value = 'traits';
traitsError.value = null;
overridesError.value = null;
overrides.value = [];
newOverrideFeatureId.value = null;
newOverrideEnabled.value = false;
newOverrideValue.value = '';
loadOverridesData();
}
},
{ immediate: true },
);
async function onToggleOverride(override: FeatureStateResponse, enabled: boolean | null): Promise<void> {
if (enabled === null || !props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
overridesError.value = null;
try {
const updated = await setIdentityFeatureState(envApiKey, props.identity.id, override.featureId, {
enabled,
value: override.value,
});
const idx = overrides.value.findIndex((o) => o.featureId === override.featureId);
if (idx >= 0) overrides.value[idx] = updated;
} catch {
overridesError.value = 'Failed to update override. Please try again.';
}
}
async function onUpdateOverrideValue(override: FeatureStateResponse, value: string): Promise<void> {
if (!props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
overridesError.value = null;
try {
const updated = await setIdentityFeatureState(envApiKey, props.identity.id, override.featureId, {
enabled: override.enabled,
value: value || null,
});
const idx = overrides.value.findIndex((o) => o.featureId === override.featureId);
if (idx >= 0) overrides.value[idx] = updated;
} catch {
overridesError.value = 'Failed to update override value. Please try again.';
}
}
async function onRemoveOverride(override: FeatureStateResponse): Promise<void> {
if (!props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
overridesError.value = null;
try {
await deleteIdentityFeatureState(envApiKey, props.identity.id, override.featureId);
overrides.value = overrides.value.filter((o) => o.featureId !== override.featureId);
} catch {
overridesError.value = 'Failed to remove override. Please try again.';
}
}
async function onAddOverride(): Promise<void> {
if (!newOverrideFeatureId.value || !props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
isAddingOverride.value = true;
overridesError.value = null;
try {
const created = await setIdentityFeatureState(envApiKey, props.identity.id, newOverrideFeatureId.value, {
enabled: newOverrideEnabled.value,
value: newOverrideValue.value || null,
});
overrides.value = [...overrides.value, created];
newOverrideFeatureId.value = null;
newOverrideEnabled.value = false;
newOverrideValue.value = '';
} catch {
overridesError.value = 'Failed to add override. Please try again.';
} finally {
isAddingOverride.value = false;
}
}
async function onDeleteIdentity(): Promise<void> {
if (!props.identity) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
isDeleting.value = true;
traitsError.value = null;
try {
await deleteIdentity(envApiKey, props.identity.id);
emit('deleted', props.identity.id);
emit('update:modelValue', false);
} catch {
traitsError.value = 'Failed to delete identity. Please try again.';
} finally {
isDeleting.value = false;
}
}
</script>

View File

@@ -1,18 +1,175 @@
<template>
<v-row>
<v-col cols="12">
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Identities</h1>
</div>
<v-card>
<v-card-text class="text-medium-emphasis">
Identities represent the individual users or services that consume your feature flags.
Select an environment to manage identities.
<!-- No environment selected -->
<v-card v-if="!contextStore.currentEnvironment" variant="outlined" rounded="lg">
<v-card-text class="text-center py-10">
<v-icon size="48" color="medium-emphasis" class="mb-3">mdi-account-outline</v-icon>
<p class="text-h6 mb-2">Select an environment</p>
<p class="text-body-2 text-medium-emphasis">
Choose an environment to view and manage identities.
</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
<template v-else>
<!-- Search bar -->
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search identities"
variant="outlined"
density="comfortable"
clearable
hide-details
class="mb-4"
data-testid="identity-search"
/>
<!-- Identities table -->
<v-card rounded="lg">
<v-data-table
:headers="headers"
:items="filteredIdentities"
:loading="isLoading"
:items-per-page="20"
hover
data-testid="identities-table"
@click:row="onRowClick"
>
<!-- Identifier -->
<template #item.identifier="{ item }: { item: IdentityResponse }">
<span class="text-body-2 font-weight-medium">{{ item.identifier }}</span>
</template>
<!-- Trait count -->
<template #item.traits="{ item }: { item: IdentityResponse }">
<span class="text-body-2 text-medium-emphasis">{{ item.traits.length }}</span>
</template>
<!-- Created at -->
<template #item.createdAt="{ item }: { item: IdentityResponse }">
<span class="text-caption text-medium-emphasis">{{ formatDate(item.createdAt) }}</span>
</template>
<!-- Row actions -->
<template #item.actions="{ item }: { item: IdentityResponse }">
<v-btn
icon="mdi-eye-outline"
size="small"
variant="text"
:data-testid="`view-identity-${item.id}`"
@click.stop="openDetail(item)"
/>
</template>
<!-- Empty state -->
<template #no-data>
<div class="text-center py-8">
<v-icon size="40" color="medium-emphasis" class="mb-2">mdi-account-outline</v-icon>
<p class="text-body-2 text-medium-emphasis">No identities found in this environment.</p>
</div>
</template>
</v-data-table>
</v-card>
</template>
<!-- Identity detail dialog -->
<IdentityDetail
v-model="showDetail"
:identity="selectedIdentity"
@deleted="onIdentityDeleted"
/>
<!-- Error snackbar -->
<v-snackbar
v-model="showErrorSnackbar"
color="error"
:timeout="4000"
location="bottom"
>
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="showErrorSnackbar = false">Dismiss</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { listIdentities } from '@/api/identities';
import { useContextStore } from '@/stores/context';
import IdentityDetail from '@/components/identities/IdentityDetail.vue';
import type { IdentityResponse } from '@/types/api';
const contextStore = useContextStore();
const identities = ref<IdentityResponse[]>([]);
const isLoading = ref(false);
const search = ref('');
const showErrorSnackbar = ref(false);
const snackbarMessage = ref('');
const showDetail = ref(false);
const selectedIdentity = ref<IdentityResponse | null>(null);
const headers = [
{ title: 'Identifier', key: 'identifier', sortable: true },
{ title: 'Traits', key: 'traits', sortable: false, width: '80' },
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
{ title: '', key: 'actions', sortable: false, width: '50', align: 'end' as const },
];
function showError(message: string): void {
snackbarMessage.value = message;
showErrorSnackbar.value = true;
}
const filteredIdentities = computed(() => {
if (!search.value) return identities.value;
const q = search.value.toLowerCase();
return identities.value.filter((i) => i.identifier.toLowerCase().includes(q));
});
async function loadIdentities(): Promise<void> {
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) {
identities.value = [];
return;
}
isLoading.value = true;
try {
const result = await listIdentities(envApiKey, 1, 100);
identities.value = result.results;
} catch {
identities.value = [];
showError('Failed to load identities. Please refresh the page.');
} finally {
isLoading.value = false;
}
}
watch(() => contextStore.currentEnvironment, loadIdentities, { immediate: true });
function openDetail(identity: IdentityResponse): void {
selectedIdentity.value = identity;
showDetail.value = true;
}
function onRowClick(_event: Event, { item }: { item: IdentityResponse }): void {
openDetail(item);
}
function onIdentityDeleted(identityId: number): void {
identities.value = identities.value.filter((i) => i.id !== identityId);
showDetail.value = false;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>

View File

@@ -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: '<div><slot /></div>' };
function mountDetail(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
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<any>).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<any>).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<any>).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);
});
});
});

View File

@@ -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: '<div><slot /></div>' };
function mountView() {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
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);
});
});
});