diff --git a/src/admin/src/components/features/FeatureDetail.vue b/src/admin/src/components/features/FeatureDetail.vue
index d8e7cd4..6a6a2e9 100755
--- a/src/admin/src/components/features/FeatureDetail.vue
+++ b/src/admin/src/components/features/FeatureDetail.vue
@@ -274,12 +274,22 @@
data-testid="settings-default-enabled-toggle"
/>
+
@@ -440,6 +450,8 @@ const settingsForm = ref({
defaultEnabled: false,
});
+const deleteConfirmName = ref('');
+
const rules = {
required: (v: string) => !!v || 'Required',
nameFormat: (v: string) =>
@@ -481,6 +493,7 @@ watch(
newSegmentId.value = null;
newSegmentEnabled.value = true;
newSegmentPriority.value = 1;
+ deleteConfirmName.value = '';
syncSettingsForm(props.feature);
editedValue.value = props.featureState?.value ?? null;
diff --git a/src/admin/src/router/index.ts b/src/admin/src/router/index.ts
index 875e563..05c9c0d 100755
--- a/src/admin/src/router/index.ts
+++ b/src/admin/src/router/index.ts
@@ -1,4 +1,4 @@
-import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
+import { createRouter, createWebHistory, RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
import { getAccessToken } from '@/api/client';
const routes: RouteRecordRaw[] = [
@@ -102,7 +102,7 @@ const router = createRouter({
// ─── Auth Guard ───────────────────────────────────────────────────────────────
-router.beforeEach((to) => {
+export function authGuard(to: RouteLocationNormalized) {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
const isPublic = to.matched.some((record) => record.meta.public);
@@ -116,6 +116,8 @@ router.beforeEach((to) => {
}
return true;
-});
+}
+
+router.beforeEach(authGuard);
export default router;
diff --git a/src/admin/tests/unit/api/client.spec.ts b/src/admin/tests/unit/api/client.spec.ts
index 38c2436..38932c6 100755
--- a/src/admin/tests/unit/api/client.spec.ts
+++ b/src/admin/tests/unit/api/client.spec.ts
@@ -1,51 +1,274 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
-import { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
+import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
+import apiClient, { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
+import type { TokenResponse } from '@/types/api';
-// The interceptor internals rely on axios, which is hard to unit-test without
-// a real HTTP stack. These tests focus on the exported token-management helpers
-// that the interceptors and auth store both depend on.
+// The response interceptor drives its retry/refresh flow entirely through the
+// apiClient axios instance, so we replace its transport (`adapter`) with a
+// fake one we control. This exercises the real interceptor chain end-to-end
+// (request auth header, 401 detection, refresh, retry, queueing) rather than
+// just the token-storage helpers. The refresh call itself goes through the
+// raw `axios.post` (not the apiClient instance, to avoid interceptor
+// recursion), so that's mocked separately via jest.spyOn.
+
+function unauthorizedError(config: InternalAxiosRequestConfig): AxiosError {
+ return new AxiosError(
+ 'Request failed with status code 401',
+ 'ERR_BAD_REQUEST',
+ config,
+ undefined,
+ { status: 401, statusText: 'Unauthorized', data: {}, headers: {}, config },
+ );
+}
+
+function serverError(config: InternalAxiosRequestConfig): AxiosError {
+ return new AxiosError(
+ 'Request failed with status code 500',
+ 'ERR_BAD_RESPONSE',
+ config,
+ undefined,
+ { status: 500, statusText: 'Internal Server Error', data: {}, headers: {}, config },
+ );
+}
+
+const tokenResponse: TokenResponse = {
+ accessToken: 'new-access-token',
+ refreshToken: 'new-refresh-token',
+ expiresAt: '2026-01-01T01:00:00Z',
+};
+
+describe('ApiClient', () => {
+ let originalLocation: Location;
-describe('ApiClient token management', () => {
beforeEach(() => {
clearAuthTokens();
localStorage.clear();
+ jest.restoreAllMocks();
+
+ originalLocation = window.location;
+ // jsdom's window.location.href setter throws "not implemented" - stub it
+ // out so the redirect-on-refresh-failure path can be asserted on.
+ delete (window as any).location;
+ window.location = { ...originalLocation, href: '' } as Location;
});
afterEach(() => {
clearAuthTokens();
+ window.location = originalLocation;
});
- describe('WhenTokensAreSet', () => {
- it('ThenGetAccessTokenReturnsTheAccessToken', () => {
- setAuthTokens('access-abc', 'refresh-xyz');
+ describe('Token management', () => {
+ describe('WhenTokensAreSet', () => {
+ it('ThenGetAccessTokenReturnsTheAccessToken', () => {
+ setAuthTokens('access-abc', 'refresh-xyz');
- expect(getAccessToken()).toBe('access-abc');
+ expect(getAccessToken()).toBe('access-abc');
+ });
+ });
+
+ describe('WhenTokensAreCleared', () => {
+ it('ThenGetAccessTokenReturnsNull', () => {
+ setAuthTokens('access-abc', 'refresh-xyz');
+ clearAuthTokens();
+
+ expect(getAccessToken()).toBeNull();
+ });
+ });
+
+ describe('WhenSetAuthTokensIsCalledWithNull', () => {
+ it('ThenGetAccessTokenReturnsNull', () => {
+ setAuthTokens(null, null);
+
+ expect(getAccessToken()).toBeNull();
+ });
+ });
+
+ describe('WhenTokensAreUpdated', () => {
+ it('ThenGetAccessTokenReturnsTheLatestToken', () => {
+ setAuthTokens('first-token', 'first-refresh');
+ setAuthTokens('second-token', 'second-refresh');
+
+ expect(getAccessToken()).toBe('second-token');
+ });
});
});
- describe('WhenTokensAreCleared', () => {
- it('ThenGetAccessTokenReturnsNull', () => {
- setAuthTokens('access-abc', 'refresh-xyz');
- clearAuthTokens();
+ describe('Request interceptor', () => {
+ describe('WhenAnAccessTokenIsSet', () => {
+ it('ThenTheRequestCarriesABearerAuthorizationHeader', async () => {
+ setAuthTokens('access-abc', 'refresh-xyz');
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
+ data: {}, status: 200, statusText: 'OK', headers: {}, config,
+ }));
+ apiClient.defaults.adapter = adapter;
- expect(getAccessToken()).toBeNull();
+ await apiClient.get('/v1/whatever');
+
+ expect(adapter.mock.calls[0][0].headers.Authorization).toBe('Bearer access-abc');
+ });
+ });
+
+ describe('WhenNoAccessTokenIsSet', () => {
+ it('ThenTheRequestHasNoAuthorizationHeader', async () => {
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
+ data: {}, status: 200, statusText: 'OK', headers: {}, config,
+ }));
+ apiClient.defaults.adapter = adapter;
+
+ await apiClient.get('/v1/whatever');
+
+ expect(adapter.mock.calls[0][0].headers.Authorization).toBeUndefined();
+ });
});
});
- describe('WhenSetAuthTokensIsCalledWithNull', () => {
- it('ThenGetAccessTokenReturnsNull', () => {
- setAuthTokens(null, null);
+ describe('Response interceptor - 401 refresh flow', () => {
+ describe('WhenARequestFailsWith401AndARefreshTokenExists', () => {
+ it('ThenTheSessionIsRefreshedAndTheOriginalRequestIsRetried', async () => {
+ setAuthTokens('expired-access-token', 'valid-refresh-token');
- expect(getAccessToken()).toBeNull();
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
+ if (config.headers.Authorization === 'Bearer new-access-token') {
+ return { data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config };
+ }
+ throw unauthorizedError(config);
+ });
+ apiClient.defaults.adapter = adapter;
+
+ const postSpy = jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
+
+ const response = await apiClient.get('/v1/protected');
+
+ expect(postSpy).toHaveBeenCalledWith(
+ expect.stringContaining('/v1/auth/refresh'),
+ { token: 'valid-refresh-token' },
+ expect.anything(),
+ );
+ expect(response.data).toEqual({ ok: true });
+ expect(adapter).toHaveBeenCalledTimes(2);
+ expect(getAccessToken()).toBe('new-access-token');
+ expect(localStorage.getItem('mic_access_token')).toBe('new-access-token');
+ expect(localStorage.getItem('mic_refresh_token')).toBe('new-refresh-token');
+ expect(localStorage.getItem('mic_expires_at')).toBe(tokenResponse.expiresAt);
+ });
});
- });
- describe('WhenTokensAreUpdated', () => {
- it('ThenGetAccessTokenReturnsTheLatestToken', () => {
- setAuthTokens('first-token', 'first-refresh');
- setAuthTokens('second-token', 'second-refresh');
+ describe('WhenTheRefreshRequestItselfFails', () => {
+ it('ThenTokensAreClearedAndTheUserIsRedirectedToLogin', async () => {
+ setAuthTokens('expired-access-token', 'invalid-refresh-token');
+ localStorage.setItem('mic_access_token', 'expired-access-token');
+ localStorage.setItem('mic_refresh_token', 'invalid-refresh-token');
+ localStorage.setItem('mic_expires_at', '2026-01-01T00:00:00Z');
- expect(getAccessToken()).toBe('second-token');
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
+ throw unauthorizedError(config);
+ });
+ apiClient.defaults.adapter = adapter;
+
+ jest.spyOn(axios, 'post').mockRejectedValue(new Error('refresh endpoint unreachable'));
+
+ await expect(apiClient.get('/v1/protected')).rejects.toThrow();
+
+ expect(getAccessToken()).toBeNull();
+ expect(localStorage.getItem('mic_access_token')).toBeNull();
+ expect(localStorage.getItem('mic_refresh_token')).toBeNull();
+ expect(localStorage.getItem('mic_expires_at')).toBeNull();
+ expect(window.location.href).toBe('/login');
+ });
+ });
+
+ describe('WhenThereIsNoRefreshTokenAvailable', () => {
+ it('ThenTheOriginalErrorIsRejectedWithoutAttemptingRefresh', async () => {
+ setAuthTokens(null, null);
+
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
+ throw unauthorizedError(config);
+ });
+ apiClient.defaults.adapter = adapter;
+
+ const postSpy = jest.spyOn(axios, 'post');
+
+ await expect(apiClient.get('/v1/protected')).rejects.toThrow();
+
+ expect(postSpy).not.toHaveBeenCalled();
+ expect(adapter).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('WhenARequestFailsWithANonAuthError', () => {
+ it('ThenTheErrorIsRejectedWithoutAttemptingRefresh', async () => {
+ setAuthTokens('access-abc', 'refresh-xyz');
+
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
+ throw serverError(config);
+ });
+ apiClient.defaults.adapter = adapter;
+
+ const postSpy = jest.spyOn(axios, 'post');
+
+ await expect(apiClient.get('/v1/whatever')).rejects.toThrow();
+
+ expect(postSpy).not.toHaveBeenCalled();
+ expect(adapter).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('WhenARetriedRequestFailsWith401Again', () => {
+ it('ThenTheErrorIsRejectedWithoutLoopingForever', async () => {
+ setAuthTokens('expired-access-token', 'valid-refresh-token');
+
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig & { _retried?: boolean }) => {
+ if (config._retried) {
+ throw unauthorizedError(config);
+ }
+ throw unauthorizedError(config);
+ });
+ apiClient.defaults.adapter = adapter;
+
+ jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
+
+ await expect(apiClient.get('/v1/protected')).rejects.toThrow();
+
+ // One failed attempt + one retry after refresh; the retry itself
+ // is marked _retried so a second 401 does not trigger another refresh.
+ expect(adapter).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('WhenMultipleRequestsFailWith401WhileARefreshIsAlreadyInFlight', () => {
+ it('ThenAllRequestsAreQueuedAndRetriedWithTheRefreshedToken', async () => {
+ setAuthTokens('expired-access-token', 'valid-refresh-token');
+
+ let resolveRefresh!: (value: { data: TokenResponse }) => void;
+ const refreshPromise = new Promise<{ data: TokenResponse }>((resolve) => {
+ resolveRefresh = resolve;
+ });
+ jest.spyOn(axios, 'post').mockReturnValue(refreshPromise as any);
+
+ const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
+ if (config.headers.Authorization === 'Bearer new-access-token') {
+ return { data: { url: config.url }, status: 200, statusText: 'OK', headers: {}, config };
+ }
+ throw unauthorizedError(config);
+ });
+ apiClient.defaults.adapter = adapter;
+
+ const first = apiClient.get('/v1/first');
+ const second = apiClient.get('/v1/second');
+
+ // Let both initial 401s resolve before the refresh completes.
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ resolveRefresh({ data: tokenResponse });
+
+ const [firstResponse, secondResponse] = await Promise.all([first, second]);
+
+ expect(firstResponse.data).toEqual({ url: '/v1/first' });
+ expect(secondResponse.data).toEqual({ url: '/v1/second' });
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ });
});
});
});
diff --git a/src/admin/tests/unit/components/segments/ConditionEditor.spec.ts b/src/admin/tests/unit/components/segments/ConditionEditor.spec.ts
new file mode 100644
index 0000000..bf7a712
--- /dev/null
+++ b/src/admin/tests/unit/components/segments/ConditionEditor.spec.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from '@jest/globals';
+import { mount, type VueWrapper } from '@vue/test-utils';
+import ConditionEditor from '@/components/segments/ConditionEditor.vue';
+import type { SegmentCondition } from '@/types/api';
+
+function mountEditor(condition: SegmentCondition) {
+ return mount(ConditionEditor, { props: { condition } });
+}
+
+describe('ConditionEditor', () => {
+ describe('WhenThePropertyFieldIsChanged', () => {
+ it('ThenUpdateConditionIsEmittedWithTheNewProperty', async () => {
+ const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
+ const wrapper = mountEditor(condition);
+
+ await (wrapper.findComponent('[data-testid="condition-property"]') as VueWrapper).vm.$emit('update:modelValue', 'country');
+
+ expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'country', operator: 'Equal', value: 'beta' }]);
+ });
+ });
+
+ describe('WhenTheOperatorIsChanged', () => {
+ it('ThenUpdateConditionIsEmittedWithTheNewOperator', async () => {
+ const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
+ const wrapper = mountEditor(condition);
+
+ await (wrapper.findComponent('[data-testid="condition-operator"]') as VueWrapper).vm.$emit('update:modelValue', 'NotEqual');
+
+ expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'plan', operator: 'NotEqual', value: 'beta' }]);
+ });
+ });
+
+ describe('WhenTheValueFieldIsChanged', () => {
+ it('ThenUpdateConditionIsEmittedWithTheNewValue', async () => {
+ const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
+ const wrapper = mountEditor(condition);
+
+ await (wrapper.findComponent('[data-testid="condition-value"]') as VueWrapper).vm.$emit('update:modelValue', 'gamma');
+
+ expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'plan', operator: 'Equal', value: 'gamma' }]);
+ });
+ });
+
+ describe('WhenTheOperatorNeedsNoValue', () => {
+ it.each(['IsTrue', 'IsFalse', 'IsSet', 'IsNotSet'] as const)(
+ 'ThenNeitherTheValueFieldNorThePercentageSliderIsShownFor %s',
+ (operator) => {
+ const condition: SegmentCondition = { property: 'plan', operator, value: '' };
+ const wrapper = mountEditor(condition);
+
+ expect(wrapper.find('[data-testid="condition-value"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="condition-percentage-slider"]').exists()).toBe(false);
+ },
+ );
+ });
+
+ describe('WhenTheOperatorIsPercentageSplit', () => {
+ it('ThenThePercentageSliderIsShownInsteadOfTheValueField', () => {
+ const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: '25' };
+ const wrapper = mountEditor(condition);
+
+ expect(wrapper.find('[data-testid="condition-percentage-slider"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="condition-value"]').exists()).toBe(false);
+ });
+
+ it('ThenAnInvalidStoredValueRendersAsZero', () => {
+ const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: 'not-a-number' };
+ const wrapper = mountEditor(condition);
+
+ const slider = wrapper.findComponent('[data-testid="condition-percentage-slider"]') as VueWrapper;
+ expect(slider.props('modelValue')).toBe(0);
+ });
+
+ it('ThenMovingTheSliderEmitsUpdateConditionWithTheValueAsAString', async () => {
+ const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: '25' };
+ const wrapper = mountEditor(condition);
+
+ await (wrapper.findComponent('[data-testid="condition-percentage-slider"]') as VueWrapper).vm.$emit('update:modelValue', 60);
+
+ expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'userId', operator: 'PercentageSplit', value: '60' }]);
+ });
+ });
+
+ describe('WhenTheRemoveButtonIsClicked', () => {
+ it('ThenRemoveIsEmitted', async () => {
+ const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
+ const wrapper = mountEditor(condition);
+
+ await wrapper.find('[data-testid="remove-condition-btn"]').trigger('click');
+
+ expect(wrapper.emitted('remove')).toBeTruthy();
+ });
+ });
+});
diff --git a/src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts b/src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts
new file mode 100644
index 0000000..decee4f
--- /dev/null
+++ b/src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts
@@ -0,0 +1,216 @@
+import { describe, it, expect } from '@jest/globals';
+import { mount, type VueWrapper } from '@vue/test-utils';
+import RuleGroupEditor from '@/components/segments/RuleGroupEditor.vue';
+import ConditionEditor from '@/components/segments/ConditionEditor.vue';
+import type { SegmentRule } from '@/types/api';
+
+function mountEditor(rule: SegmentRule, removable = false) {
+ return mount(RuleGroupEditor, { props: { rule, removable } });
+}
+
+describe('RuleGroupEditor', () => {
+ describe('WhenTheRuleTypeIsAll', () => {
+ it('ThenTheAndButtonIsHighlighted', () => {
+ const wrapper = mountEditor({ type: 'All', conditions: [] });
+
+ expect(wrapper.findComponent('[data-testid="rule-type-and"]').props('variant')).toBe('flat');
+ expect(wrapper.findComponent('[data-testid="rule-type-or"]').props('variant')).toBe('outlined');
+ });
+ });
+
+ describe('WhenTheRuleTypeIsAny', () => {
+ it('ThenTheOrButtonIsHighlighted', () => {
+ const wrapper = mountEditor({ type: 'Any', conditions: [] });
+
+ expect(wrapper.findComponent('[data-testid="rule-type-and"]').props('variant')).toBe('outlined');
+ expect(wrapper.findComponent('[data-testid="rule-type-or"]').props('variant')).toBe('flat');
+ });
+ });
+
+ describe('WhenTheOrButtonIsClicked', () => {
+ it('ThenUpdateRuleIsEmittedWithTypeAny', async () => {
+ const wrapper = mountEditor({ type: 'All', conditions: [] });
+
+ await wrapper.find('[data-testid="rule-type-or"]').trigger('click');
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{ type: 'Any', conditions: [] }]);
+ });
+ });
+
+ describe('WhenTheAndButtonIsClicked', () => {
+ it('ThenUpdateRuleIsEmittedWithTypeAll', async () => {
+ const wrapper = mountEditor({ type: 'Any', conditions: [] });
+
+ await wrapper.find('[data-testid="rule-type-and"]').trigger('click');
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{ type: 'All', conditions: [] }]);
+ });
+ });
+
+ describe('WhenRemovableIsFalse', () => {
+ it('ThenTheRemoveButtonIsNotShown', () => {
+ const wrapper = mountEditor({ type: 'All', conditions: [] }, false);
+
+ expect(wrapper.find('[data-testid="remove-rule-group-btn"]').exists()).toBe(false);
+ });
+ });
+
+ describe('WhenRemovableIsTrue', () => {
+ it('ThenTheRemoveButtonIsShownAndEmitsRemoveWhenClicked', async () => {
+ const wrapper = mountEditor({ type: 'All', conditions: [] }, true);
+
+ expect(wrapper.find('[data-testid="remove-rule-group-btn"]').exists()).toBe(true);
+
+ await wrapper.find('[data-testid="remove-rule-group-btn"]').trigger('click');
+
+ expect(wrapper.emitted('remove')).toBeTruthy();
+ });
+ });
+
+ describe('WhenTheRuleHasConditions', () => {
+ it('ThenAConditionEditorIsRenderedPerCondition', () => {
+ const wrapper = mountEditor({
+ type: 'All',
+ conditions: [
+ { property: 'plan', operator: 'Equal', value: 'beta' },
+ { property: 'country', operator: 'Equal', value: 'US' },
+ ],
+ });
+
+ expect(wrapper.findAllComponents(ConditionEditor)).toHaveLength(2);
+ });
+ });
+
+ describe('WhenAConditionIsUpdated', () => {
+ it('ThenUpdateRuleIsEmittedWithThatConditionReplacedInPlace', async () => {
+ const wrapper = mountEditor({
+ type: 'All',
+ conditions: [
+ { property: 'plan', operator: 'Equal', value: 'beta' },
+ { property: 'country', operator: 'Equal', value: 'US' },
+ ],
+ });
+
+ const conditionEditors = wrapper.findAllComponents(ConditionEditor);
+ await conditionEditors[1].vm.$emit('update:condition', { property: 'country', operator: 'Equal', value: 'CA' });
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{
+ type: 'All',
+ conditions: [
+ { property: 'plan', operator: 'Equal', value: 'beta' },
+ { property: 'country', operator: 'Equal', value: 'CA' },
+ ],
+ }]);
+ });
+ });
+
+ describe('WhenAConditionIsRemoved', () => {
+ it('ThenUpdateRuleIsEmittedWithThatConditionFilteredOut', async () => {
+ const wrapper = mountEditor({
+ type: 'All',
+ conditions: [
+ { property: 'plan', operator: 'Equal', value: 'beta' },
+ { property: 'country', operator: 'Equal', value: 'US' },
+ ],
+ });
+
+ const conditionEditors = wrapper.findAllComponents(ConditionEditor);
+ await conditionEditors[0].vm.$emit('remove');
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{
+ type: 'All',
+ conditions: [{ property: 'country', operator: 'Equal', value: 'US' }],
+ }]);
+ });
+ });
+
+ describe('WhenAddConditionIsClicked', () => {
+ it('ThenUpdateRuleIsEmittedWithANewEmptyConditionAppended', async () => {
+ const wrapper = mountEditor({
+ type: 'All',
+ conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }],
+ });
+
+ await wrapper.find('[data-testid="add-condition-btn"]').trigger('click');
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{
+ type: 'All',
+ conditions: [
+ { property: 'plan', operator: 'Equal', value: 'beta' },
+ { property: '', operator: 'Equal', value: '' },
+ ],
+ }]);
+ });
+ });
+
+ describe('WhenAddGroupIsClicked', () => {
+ it('ThenUpdateRuleIsEmittedWithANewChildGroupAppended', async () => {
+ const wrapper = mountEditor({ type: 'All', conditions: [] });
+
+ await wrapper.find('[data-testid="add-group-btn"]').trigger('click');
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{
+ type: 'All',
+ conditions: [],
+ childRules: [{ type: 'All', conditions: [] }],
+ }]);
+ });
+ });
+
+ describe('WhenTheRuleHasChildRules', () => {
+ it('ThenANestedRuleGroupEditorIsRenderedPerChildAndMarkedRemovable', () => {
+ const wrapper = mountEditor({
+ type: 'All',
+ conditions: [],
+ childRules: [{ type: 'Any', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] }],
+ });
+
+ const nested = wrapper.findAllComponents(RuleGroupEditor);
+ expect(nested).toHaveLength(1);
+ expect(nested[0].props('rule')).toEqual({ type: 'Any', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] });
+ expect(nested[0].props('removable')).toBe(true);
+ });
+ });
+
+ describe('WhenANestedRuleGroupIsUpdated', () => {
+ it('ThenUpdateRuleIsEmittedWithThatChildReplacedInPlace', async () => {
+ const wrapper = mountEditor({
+ type: 'All',
+ conditions: [],
+ childRules: [{ type: 'Any', conditions: [] }],
+ });
+
+ const nested = wrapper.findAllComponents(RuleGroupEditor);
+ const updatedChild: SegmentRule = { type: 'All', conditions: [{ property: 'x', operator: 'Equal', value: 'y' }] };
+ await nested[0].vm.$emit('update:rule', updatedChild);
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{
+ type: 'All',
+ conditions: [],
+ childRules: [updatedChild],
+ }]);
+ });
+ });
+
+ describe('WhenANestedRuleGroupIsRemoved', () => {
+ it('ThenUpdateRuleIsEmittedWithThatChildFilteredOut', async () => {
+ const wrapper = mountEditor({
+ type: 'All',
+ conditions: [],
+ childRules: [
+ { type: 'Any', conditions: [] },
+ { type: 'All', conditions: [] },
+ ],
+ });
+
+ const nested = wrapper.findAllComponents(RuleGroupEditor);
+ await nested[0].vm.$emit('remove');
+
+ expect(wrapper.emitted('update:rule')![0]).toEqual([{
+ type: 'All',
+ conditions: [],
+ childRules: [{ type: 'All', conditions: [] }],
+ }]);
+ });
+ });
+});
diff --git a/src/admin/tests/unit/router/router.spec.ts b/src/admin/tests/unit/router/router.spec.ts
index 1a63f2f..e1af7ce 100755
--- a/src/admin/tests/unit/router/router.spec.ts
+++ b/src/admin/tests/unit/router/router.spec.ts
@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { createRouter, createMemoryHistory } from 'vue-router';
import { setActivePinia, createPinia } from 'pinia';
-import { setAuthTokens, clearAuthTokens } from '@/api/client';
+import { clearAuthTokens } from '@/api/client';
+import { authGuard } from '@/router';
// Minimal stub components for route testing
const Stub = { template: '' };
@@ -15,11 +16,17 @@ jest.mock('@/api/client', () => ({
import { getAccessToken } from '@/api/client';
function buildRouter() {
- return createRouter({
+ const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/login', name: 'Login', component: Stub, meta: { public: true } },
{ path: '/register', name: 'Register', component: Stub, meta: { public: true } },
+ {
+ path: '/accept-invite/:token',
+ name: 'AcceptInvite',
+ component: Stub,
+ meta: { public: true, allowAuthenticated: true },
+ },
{
path: '/',
component: Stub,
@@ -31,6 +38,9 @@ function buildRouter() {
},
],
});
+ // Wire up the real guard exported by the app router, not a re-implementation.
+ router.beforeEach(authGuard);
+ return router;
}
describe('Router auth guard', () => {
@@ -44,21 +54,10 @@ describe('Router auth guard', () => {
});
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
- it('ThenTheyAreRedirectedToLogin', async () => {
+ it('ThenTheyAreRedirectedToLoginWithARedirectQuery', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter();
-
- // Wire up the same guard logic as the real router
- router.beforeEach((to) => {
- const isAuthenticated = !!getAccessToken();
- const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
- const isPublic = to.matched.some((r) => r.meta.public);
- if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
- if (isPublic && isAuthenticated) return { name: 'Dashboard' };
- return true;
- });
-
await router.push('/features');
expect(router.currentRoute.value.name).toBe('Login');
@@ -71,17 +70,6 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
-
- router.beforeEach((to) => {
- const isAuthenticated = !!getAccessToken();
- const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
- const isPublic = to.matched.some((r) => r.meta.public);
- if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
- if (isPublic && isAuthenticated) return { name: 'Dashboard' };
- return true;
- });
-
- // Start authenticated on dashboard, then try to go to login
await router.push('/');
await router.push('/login');
@@ -94,19 +82,55 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
-
- router.beforeEach((to) => {
- const isAuthenticated = !!getAccessToken();
- const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
- const isPublic = to.matched.some((r) => r.meta.public);
- if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
- if (isPublic && isAuthenticated) return { name: 'Dashboard' };
- return true;
- });
-
await router.push('/features');
expect(router.currentRoute.value.name).toBe('Features');
});
});
+
+ describe('WhenUnauthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
+ it('ThenNavigationSucceeds', async () => {
+ jest.mocked(getAccessToken).mockReturnValue(null);
+
+ const router = buildRouter();
+ await router.push('/accept-invite/some-token');
+
+ expect(router.currentRoute.value.name).toBe('AcceptInvite');
+ });
+ });
+
+ describe('WhenAuthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
+ it('ThenTheyAreNotRedirectedToDashboard', async () => {
+ jest.mocked(getAccessToken).mockReturnValue('valid-token');
+
+ const router = buildRouter();
+ await router.push('/accept-invite/some-token');
+
+ expect(router.currentRoute.value.name).toBe('AcceptInvite');
+ });
+ });
+
+ describe('WhenAuthenticatedUserNavigatesToRegister', () => {
+ it('ThenTheyAreRedirectedToDashboardBecauseRegisterHasNoAllowAuthenticatedException', async () => {
+ jest.mocked(getAccessToken).mockReturnValue('valid-token');
+
+ const router = buildRouter();
+ await router.push('/');
+ await router.push('/register');
+
+ expect(router.currentRoute.value.name).toBe('Dashboard');
+ });
+ });
+
+ describe('WhenUnauthenticatedUserNavigatesToAProtectedRouteWithAQueryString', () => {
+ it('ThenTheFullPathIncludingTheQueryIsPreservedInTheRedirect', async () => {
+ jest.mocked(getAccessToken).mockReturnValue(null);
+
+ const router = buildRouter();
+ await router.push('/features?tag=beta');
+
+ expect(router.currentRoute.value.name).toBe('Login');
+ expect(router.currentRoute.value.query.redirect).toBe('/features?tag=beta');
+ });
+ });
});
diff --git a/src/admin/tests/unit/stores/theme.spec.ts b/src/admin/tests/unit/stores/theme.spec.ts
new file mode 100644
index 0000000..3986c97
--- /dev/null
+++ b/src/admin/tests/unit/stores/theme.spec.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect, beforeEach, jest } from '@jest/globals';
+import { setActivePinia, createPinia } from 'pinia';
+import { useThemeStore } from '@/stores/theme';
+
+const STORAGE_KEY = 'mic_theme';
+
+function mockMatchMedia(initialMatches: boolean) {
+ let changeHandler: ((e: { matches: boolean }) => void) | undefined;
+ const mql = {
+ matches: initialMatches,
+ media: '(prefers-color-scheme: dark)',
+ addEventListener: jest.fn((event: string, handler: (e: { matches: boolean }) => void) => {
+ if (event === 'change') changeHandler = handler;
+ }),
+ removeEventListener: jest.fn(),
+ };
+ window.matchMedia = jest.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
+
+ return {
+ triggerChange: (matches: boolean) => changeHandler?.({ matches }),
+ };
+}
+
+describe('ThemeStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ localStorage.clear();
+ });
+
+ describe('WhenNoModeIsStoredAndSystemPrefersLight', () => {
+ it('ThenModeDefaultsToSystemAndEffectiveThemeIsLight', () => {
+ mockMatchMedia(false);
+
+ const themeStore = useThemeStore();
+
+ expect(themeStore.mode).toBe('system');
+ expect(themeStore.effectiveTheme).toBe('light');
+ });
+ });
+
+ describe('WhenNoModeIsStoredAndSystemPrefersDark', () => {
+ it('ThenEffectiveThemeIsDark', () => {
+ mockMatchMedia(true);
+
+ const themeStore = useThemeStore();
+
+ expect(themeStore.mode).toBe('system');
+ expect(themeStore.effectiveTheme).toBe('dark');
+ });
+ });
+
+ describe('WhenAModeIsAlreadyStored', () => {
+ it('ThenTheStoredModeIsUsedAsTheInitialMode', () => {
+ localStorage.setItem(STORAGE_KEY, 'dark');
+ mockMatchMedia(false);
+
+ const themeStore = useThemeStore();
+
+ expect(themeStore.mode).toBe('dark');
+ expect(themeStore.effectiveTheme).toBe('dark');
+ });
+ });
+
+ describe('WhenSetModeIsCalledWithLight', () => {
+ it('ThenModeAndEffectiveThemeAreLightAndPersistedToStorage', () => {
+ mockMatchMedia(true);
+ const themeStore = useThemeStore();
+
+ themeStore.setMode('light');
+
+ expect(themeStore.mode).toBe('light');
+ expect(themeStore.effectiveTheme).toBe('light');
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('light');
+ });
+ });
+
+ describe('WhenSetModeIsCalledWithDark', () => {
+ it('ThenModeAndEffectiveThemeAreDarkAndPersistedToStorage', () => {
+ mockMatchMedia(false);
+ const themeStore = useThemeStore();
+
+ themeStore.setMode('dark');
+
+ expect(themeStore.mode).toBe('dark');
+ expect(themeStore.effectiveTheme).toBe('dark');
+ expect(localStorage.getItem(STORAGE_KEY)).toBe('dark');
+ });
+ });
+
+ describe('WhenModeIsSystemAndTheOperatingSystemThemeChanges', () => {
+ it('ThenEffectiveThemeReactsToTheChange', () => {
+ const { triggerChange } = mockMatchMedia(false);
+ const themeStore = useThemeStore();
+ expect(themeStore.effectiveTheme).toBe('light');
+
+ triggerChange(true);
+
+ expect(themeStore.effectiveTheme).toBe('dark');
+ });
+ });
+
+ describe('WhenModeIsExplicitAndTheOperatingSystemThemeChanges', () => {
+ it('ThenEffectiveThemeIsUnaffected', () => {
+ const { triggerChange } = mockMatchMedia(false);
+ const themeStore = useThemeStore();
+ themeStore.setMode('light');
+
+ triggerChange(true);
+
+ expect(themeStore.effectiveTheme).toBe('light');
+ });
+ });
+});
diff --git a/src/admin/tests/unit/utils/validation.spec.ts b/src/admin/tests/unit/utils/validation.spec.ts
new file mode 100644
index 0000000..853cb92
--- /dev/null
+++ b/src/admin/tests/unit/utils/validation.spec.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect } from '@jest/globals';
+import { validateEmail } from '@/utils/validation';
+
+describe('validateEmail', () => {
+ describe('WhenTheValueIsEmpty', () => {
+ it('ThenItReturnsAnEmailRequiredMessage', () => {
+ expect(validateEmail('')).toBe('Email required');
+ });
+ });
+
+ describe('WhenTheValueIsOnlyWhitespace', () => {
+ it('ThenItReturnsAnEmailRequiredMessage', () => {
+ expect(validateEmail(' ')).toBe('Email required');
+ });
+ });
+
+ describe('WhenTheValueIsAValidEmail', () => {
+ it('ThenItReturnsTrue', () => {
+ expect(validateEmail('jane@example.com')).toBe(true);
+ });
+ });
+
+ describe('WhenTheValueHasSurroundingWhitespace', () => {
+ it('ThenItIsTrimmedBeforeValidationAndReturnsTrue', () => {
+ expect(validateEmail(' jane@example.com ')).toBe(true);
+ });
+ });
+
+ describe('WhenTheValueIsMissingTheAtSymbol', () => {
+ it('ThenItReturnsAnInvalidEmailMessage', () => {
+ expect(validateEmail('jane.example.com')).toBe('Invalid email');
+ });
+ });
+
+ describe('WhenTheValueIsMissingTheDomain', () => {
+ it('ThenItReturnsAnInvalidEmailMessage', () => {
+ expect(validateEmail('jane@example')).toBe('Invalid email');
+ });
+ });
+
+ describe('WhenTheValueContainsSpaces', () => {
+ it('ThenItReturnsAnInvalidEmailMessage', () => {
+ expect(validateEmail('jane doe@example.com')).toBe('Invalid email');
+ });
+ });
+
+ describe('WhenTheValueHasMultipleAtSymbols', () => {
+ it('ThenItReturnsAnInvalidEmailMessage', () => {
+ expect(validateEmail('jane@doe@example.com')).toBe('Invalid email');
+ });
+ });
+});
diff --git a/src/admin/tests/unit/views/AcceptInviteView.spec.ts b/src/admin/tests/unit/views/AcceptInviteView.spec.ts
new file mode 100644
index 0000000..c004e08
--- /dev/null
+++ b/src/admin/tests/unit/views/AcceptInviteView.spec.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, beforeEach, jest } from '@jest/globals';
+import { mount, flushPromises } from '@vue/test-utils';
+import { createRouter, createMemoryHistory } from 'vue-router';
+import AcceptInviteView from '@/views/AcceptInviteView.vue';
+
+jest.mock('@/api/organizations');
+jest.mock('@/api/client', () => ({
+ setAuthTokens: jest.fn(),
+ clearAuthTokens: jest.fn(),
+ getAccessToken: jest.fn(),
+}));
+
+import * as orgsApi from '@/api/organizations';
+import { getAccessToken } from '@/api/client';
+
+async function mountView(token = 'invite-token-123') {
+ const routes = [
+ { path: '/accept-invite/:token', name: 'AcceptInvite', component: AcceptInviteView },
+ { path: '/dashboard', component: { template: '' } },
+ { path: '/login', name: 'Login', component: { template: '' } },
+ ];
+ const router = createRouter({ history: createMemoryHistory(), routes });
+ router.push(`/accept-invite/${token}`);
+ await router.isReady();
+ return { wrapper: mount(AcceptInviteView, { global: { plugins: [router] } }), router };
+}
+
+describe('AcceptInviteView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('WhenTheUserIsNotAuthenticated', () => {
+ it('ThenTheSignInPromptIsShownAndAcceptInviteIsNotCalled', async () => {
+ jest.mocked(getAccessToken).mockReturnValue(null);
+
+ const { wrapper } = await mountView();
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Sign in to accept invite');
+ expect(orgsApi.acceptInvite).not.toHaveBeenCalled();
+ });
+
+ it('ThenTheSignInButtonNavigatesToLoginWithARedirectQuery', async () => {
+ jest.mocked(getAccessToken).mockReturnValue(null);
+
+ const { wrapper, router } = await mountView('invite-token-123');
+ await flushPromises();
+ const pushSpy = jest.spyOn(router, 'push');
+
+ await wrapper.find('button').trigger('click');
+
+ expect(pushSpy).toHaveBeenCalledWith({
+ name: 'Login',
+ query: { redirect: '/accept-invite/invite-token-123' },
+ });
+ });
+ });
+
+ describe('WhenTheUserIsAuthenticatedAndTheInviteIsAccepted', () => {
+ it('ThenTheSuccessStateIsShown', async () => {
+ jest.mocked(getAccessToken).mockReturnValue('access-token');
+ jest.mocked(orgsApi.acceptInvite).mockResolvedValue(undefined);
+
+ const { wrapper } = await mountView('invite-token-123');
+ await flushPromises();
+
+ expect(orgsApi.acceptInvite).toHaveBeenCalledWith('invite-token-123');
+ expect(wrapper.text()).toContain("You're in!");
+ });
+ });
+
+ describe('WhenTheUserIsAuthenticatedButTheInviteIsInvalid', () => {
+ it('ThenTheErrorStateIsShown', async () => {
+ jest.mocked(getAccessToken).mockReturnValue('access-token');
+ jest.mocked(orgsApi.acceptInvite).mockRejectedValue(new Error('Invalid token'));
+
+ const { wrapper } = await mountView('bad-token');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Invalid invite link');
+ });
+ });
+});
diff --git a/src/admin/tests/unit/views/LoginView.spec.ts b/src/admin/tests/unit/views/LoginView.spec.ts
new file mode 100644
index 0000000..68c228c
--- /dev/null
+++ b/src/admin/tests/unit/views/LoginView.spec.ts
@@ -0,0 +1,129 @@
+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 LoginView from '@/views/LoginView.vue';
+import { useAuthStore } from '@/stores/auth';
+
+jest.mock('@/api/auth');
+jest.mock('@/api/client', () => ({
+ setAuthTokens: jest.fn(),
+ clearAuthTokens: jest.fn(),
+ getAccessToken: jest.fn(),
+}));
+
+import * as authApi from '@/api/auth';
+
+const tokenResponse = {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ expiresAt: '2026-01-01T01:00:00Z',
+};
+
+function mountView() {
+ const routes = [
+ { path: '/', component: { template: '' } },
+ { path: '/register', component: { template: '' } },
+ ];
+ const router = createRouter({ history: createMemoryHistory(), routes });
+ return { wrapper: mount(LoginView, { global: { plugins: [router] } }), router };
+}
+
+async function fillAndSubmit(wrapper: ReturnType, email: string, password: string) {
+ await wrapper.find('[data-testid="email-input"] input').setValue(email);
+ await wrapper.find('[data-testid="password-input"] input').setValue(password);
+ await wrapper.find('[data-testid="login-form"]').trigger('submit.prevent');
+ await flushPromises();
+}
+
+describe('LoginView', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ jest.clearAllMocks();
+ });
+
+ describe('WhenTheFormIsEmpty', () => {
+ it('ThenSubmitDoesNotCallLogin', async () => {
+ const { wrapper } = mountView();
+
+ await wrapper.find('[data-testid="login-form"]').trigger('submit.prevent');
+ await flushPromises();
+
+ expect(authApi.login).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('WhenTheEmailIsInvalid', () => {
+ it('ThenSubmitDoesNotCallLogin', async () => {
+ const { wrapper } = mountView();
+
+ await fillAndSubmit(wrapper, 'not-an-email', 'password123');
+
+ expect(authApi.login).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('WhenCredentialsAreValidAndLoginSucceeds', () => {
+ it('ThenAuthStoreLoginIsCalledAndUserIsRedirectedHome', async () => {
+ jest.mocked(authApi.login).mockResolvedValue(tokenResponse);
+
+ const { wrapper, router } = mountView();
+ const pushSpy = jest.spyOn(router, 'push');
+
+ await fillAndSubmit(wrapper, 'jane@example.com', 'password123');
+
+ expect(authApi.login).toHaveBeenCalledWith({ email: 'jane@example.com', password: 'password123' });
+ expect(pushSpy).toHaveBeenCalledWith('/');
+ });
+
+ it('ThenAuthStoreBecomesAuthenticated', async () => {
+ jest.mocked(authApi.login).mockResolvedValue(tokenResponse);
+
+ const { wrapper } = mountView();
+ const authStore = useAuthStore();
+
+ await fillAndSubmit(wrapper, 'jane@example.com', 'password123');
+
+ expect(authStore.isAuthenticated).toBe(true);
+ });
+ });
+
+ describe('WhenLoginFails', () => {
+ it('ThenAnErrorMessageIsShownAndUserIsNotRedirected', async () => {
+ jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
+
+ const { wrapper, router } = mountView();
+ const pushSpy = jest.spyOn(router, 'push');
+
+ await fillAndSubmit(wrapper, 'jane@example.com', 'wrong-password');
+
+ expect(wrapper.find('[data-testid="login-error"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain('Invalid email or password');
+ expect(pushSpy).not.toHaveBeenCalled();
+ });
+
+ it('ThenTheSubmitButtonIsNoLongerLoading', async () => {
+ jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
+
+ const { wrapper } = mountView();
+
+ await fillAndSubmit(wrapper, 'jane@example.com', 'wrong-password');
+
+ const submitBtn = wrapper.findComponent('[data-testid="login-submit"]');
+ expect(submitBtn.props('loading')).toBe(false);
+ });
+ });
+
+ describe('WhenThePasswordVisibilityIconIsClicked', () => {
+ it('ThenThePasswordFieldTypeToggles', async () => {
+ const { wrapper } = mountView();
+
+ const passwordInput = () => wrapper.find('[data-testid="password-input"] input');
+ expect(passwordInput().attributes('type')).toBe('password');
+
+ await wrapper.find('[data-testid="password-input"] .v-field__append-inner i').trigger('click');
+
+ expect(passwordInput().attributes('type')).toBe('text');
+ });
+ });
+});
diff --git a/src/admin/tests/unit/views/RegisterView.spec.ts b/src/admin/tests/unit/views/RegisterView.spec.ts
new file mode 100644
index 0000000..e7a2ed7
--- /dev/null
+++ b/src/admin/tests/unit/views/RegisterView.spec.ts
@@ -0,0 +1,155 @@
+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 RegisterView from '@/views/RegisterView.vue';
+import { useAuthStore } from '@/stores/auth';
+
+jest.mock('@/api/auth');
+jest.mock('@/api/client', () => ({
+ setAuthTokens: jest.fn(),
+ clearAuthTokens: jest.fn(),
+ getAccessToken: jest.fn(),
+}));
+
+import * as authApi from '@/api/auth';
+
+const tokenResponse = {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ expiresAt: '2026-01-01T01:00:00Z',
+};
+
+function mountView() {
+ const routes = [
+ { path: '/', component: { template: '' } },
+ { path: '/login', component: { template: '' } },
+ ];
+ const router = createRouter({ history: createMemoryHistory(), routes });
+ return { wrapper: mount(RegisterView, { global: { plugins: [router] } }), router };
+}
+
+interface RegisterFields {
+ firstName: string;
+ lastName: string;
+ email: string;
+ organizationName: string;
+ password: string;
+ confirmPassword: string;
+}
+
+const validFields: RegisterFields = {
+ firstName: 'Jane',
+ lastName: 'Doe',
+ email: 'jane@example.com',
+ organizationName: 'Acme',
+ password: 'password123',
+ confirmPassword: 'password123',
+};
+
+async function fillAndSubmit(wrapper: ReturnType, fields: Partial = {}) {
+ const values = { ...validFields, ...fields };
+ await wrapper.find('[data-testid="first-name-input"] input').setValue(values.firstName);
+ await wrapper.find('[data-testid="last-name-input"] input').setValue(values.lastName);
+ await wrapper.find('[data-testid="email-input"] input').setValue(values.email);
+ await wrapper.find('[data-testid="org-name-input"] input').setValue(values.organizationName);
+ await wrapper.find('[data-testid="password-input"] input').setValue(values.password);
+ await wrapper.find('[data-testid="confirm-password-input"] input').setValue(values.confirmPassword);
+ await wrapper.find('[data-testid="register-form"]').trigger('submit.prevent');
+ await flushPromises();
+}
+
+describe('RegisterView', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ jest.clearAllMocks();
+ });
+
+ describe('WhenTheFormIsEmpty', () => {
+ it('ThenSubmitDoesNotCallRegister', async () => {
+ const { wrapper } = mountView();
+
+ await wrapper.find('[data-testid="register-form"]').trigger('submit.prevent');
+ await flushPromises();
+
+ expect(authApi.register).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('WhenThePasswordIsTooShort', () => {
+ it('ThenSubmitDoesNotCallRegister', async () => {
+ const { wrapper } = mountView();
+
+ await fillAndSubmit(wrapper, { password: 'short', confirmPassword: 'short' });
+
+ expect(authApi.register).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('WhenTheConfirmPasswordDoesNotMatch', () => {
+ it('ThenSubmitDoesNotCallRegister', async () => {
+ const { wrapper } = mountView();
+
+ await fillAndSubmit(wrapper, { confirmPassword: 'somethingElse123' });
+
+ expect(authApi.register).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('WhenAllFieldsAreValidAndRegisterSucceeds', () => {
+ it('ThenAuthStoreRegisterIsCalledWithFormValuesAndUserIsRedirectedHome', async () => {
+ jest.mocked(authApi.register).mockResolvedValue(tokenResponse);
+
+ const { wrapper, router } = mountView();
+ const pushSpy = jest.spyOn(router, 'push');
+
+ await fillAndSubmit(wrapper);
+
+ expect(authApi.register).toHaveBeenCalledWith({
+ email: 'jane@example.com',
+ password: 'password123',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ organizationName: 'Acme',
+ });
+ expect(pushSpy).toHaveBeenCalledWith('/');
+ });
+
+ it('ThenAuthStoreBecomesAuthenticated', async () => {
+ jest.mocked(authApi.register).mockResolvedValue(tokenResponse);
+
+ const { wrapper } = mountView();
+ const authStore = useAuthStore();
+
+ await fillAndSubmit(wrapper);
+
+ expect(authStore.isAuthenticated).toBe(true);
+ });
+ });
+
+ describe('WhenRegisterFails', () => {
+ it('ThenAnErrorMessageIsShownAndUserIsNotRedirected', async () => {
+ jest.mocked(authApi.register).mockRejectedValue(new Error('Email already registered'));
+
+ const { wrapper, router } = mountView();
+ const pushSpy = jest.spyOn(router, 'push');
+
+ await fillAndSubmit(wrapper);
+
+ expect(wrapper.find('[data-testid="register-error"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain('Registration failed');
+ expect(pushSpy).not.toHaveBeenCalled();
+ });
+
+ it('ThenTheSubmitButtonIsNoLongerLoading', async () => {
+ jest.mocked(authApi.register).mockRejectedValue(new Error('Email already registered'));
+
+ const { wrapper } = mountView();
+
+ await fillAndSubmit(wrapper);
+
+ const submitBtn = wrapper.findComponent('[data-testid="register-submit"]');
+ expect(submitBtn.props('loading')).toBe(false);
+ });
+ });
+});
diff --git a/src/admin/tests/unit/views/SegmentsView.spec.ts b/src/admin/tests/unit/views/SegmentsView.spec.ts
index 06d5cb4..9ac1430 100755
--- a/src/admin/tests/unit/views/SegmentsView.spec.ts
+++ b/src/admin/tests/unit/views/SegmentsView.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
-import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
+import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import SegmentsView from '@/views/SegmentsView.vue';
@@ -129,7 +129,8 @@ describe('SegmentsView', () => {
const wrapper = mountView();
await flushPromises();
- await wrapper.find('[data-testid="edit-segment-1"]').trigger('click');
+ const row = wrapper.findAll('tbody tr').find((r) => r.text().includes('beta_users'));
+ await row!.trigger('click');
await wrapper.vm.$nextTick();
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
@@ -154,13 +155,6 @@ describe('SegmentsView', () => {
expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true);
- // Confirm button should be disabled before typing the name
- expect(wrapper.find('[data-testid="confirm-delete-btn"]').attributes('disabled')).toBeDefined();
-
- // Type segment name to enable button
- await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper).vm.$emit('update:modelValue', 'beta_users');
- await wrapper.vm.$nextTick();
-
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
@@ -181,9 +175,6 @@ describe('SegmentsView', () => {
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
await wrapper.vm.$nextTick();
- await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper).vm.$emit('update:modelValue', 'beta_users');
- await wrapper.vm.$nextTick();
-
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
diff --git a/src/admin/tests/unit/views/SettingsView.spec.ts b/src/admin/tests/unit/views/SettingsView.spec.ts
index 4d19021..6e88dfa 100755
--- a/src/admin/tests/unit/views/SettingsView.spec.ts
+++ b/src/admin/tests/unit/views/SettingsView.spec.ts
@@ -102,9 +102,10 @@ describe('SettingsView', () => {
});
describe('WhenInviteMemberIsClicked', () => {
- it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
- jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
- jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', role: 'User', lastLoginAt: null }]);
+ it('ThenInviteOrganizationMembersByEmailIsCalledWithEmail', async () => {
+ jest.mocked(orgsApi.inviteOrganizationMembersByEmail).mockResolvedValue([
+ { email: 'jane@example.com', success: true, error: null },
+ ]);
const contextStore = useContextStore();
contextStore.setOrganization(mockOrg);
@@ -112,13 +113,19 @@ describe('SettingsView', () => {
const wrapper = mountView();
await flushPromises();
- await (wrapper.findComponent('[data-testid="invite-user-id-input"]') as VueWrapper).vm.$emit('update:modelValue', '42');
+ await wrapper.find('[data-testid="add-member-btn"]').trigger('click');
await wrapper.vm.$nextTick();
- await wrapper.find('[data-testid="invite-member-btn"]').trigger('click');
+ await (wrapper.findComponent('[data-testid="add-member-email-0"]') as VueWrapper).vm.$emit('update:modelValue', 'jane@example.com');
+ await wrapper.vm.$nextTick();
+
+ await wrapper.find('[data-testid="confirm-add-members-btn"]').trigger('click');
await flushPromises();
- expect(orgsApi.inviteOrganizationMember).toHaveBeenCalledWith(mockOrg.id, { userId: 42, role: 'User' });
+ expect(orgsApi.inviteOrganizationMembersByEmail).toHaveBeenCalledWith(
+ mockOrg.id,
+ { invites: [{ email: 'jane@example.com', role: 'User' }] },
+ );
});
});
@@ -136,6 +143,8 @@ describe('SettingsView', () => {
expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true);
await wrapper.find('[data-testid="remove-member-5"]').trigger('click');
+ await wrapper.vm.$nextTick();
+ await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
@@ -261,6 +270,8 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="remove-permission-5"]').trigger('click');
+ await wrapper.vm.$nextTick();
+ await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5);
@@ -343,9 +354,6 @@ describe('SettingsView', () => {
await wrapper.find('[data-testid="delete-env-100"]').trigger('click');
await wrapper.vm.$nextTick();
- await (wrapper.findComponent('[data-testid="delete-env-confirm-input"]') as VueWrapper).vm.$emit('update:modelValue', 'Development');
- await wrapper.vm.$nextTick();
-
await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click');
await flushPromises();
@@ -471,6 +479,8 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click');
+ await wrapper.vm.$nextTick();
+ await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises();
expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1);