Fix stale admin specs and expand Jest coverage
- Fix SettingsView/SegmentsView specs to match current dialog-based confirm flows instead of stale assumptions. - Fix FeatureDetail delete confirmation: deleteConfirmName was referenced but never declared, so the type-to-confirm gate was dead code with no input or disabled binding. - Extract the router's auth guard as authGuard so it can be tested directly instead of duplicated inline in specs; add missing allowAuthenticated and redirect-query coverage. - Add specs for previously untested modules: api/client.ts (401 refresh/retry/queueing), LoginView, RegisterView, AcceptInviteView, stores/theme.ts, utils/validation.ts, ConditionEditor, and RuleGroupEditor.
This commit is contained in:
@@ -274,12 +274,22 @@
|
||||
data-testid="settings-default-enabled-toggle"
|
||||
/>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="deleteConfirmName"
|
||||
:label="`Type '${feature?.name}' to confirm deletion`"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
hide-details
|
||||
data-testid="delete-confirm-input"
|
||||
/>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
:loading="isDeleting"
|
||||
:disabled="deleteConfirmName !== feature?.name"
|
||||
data-testid="delete-feature-btn"
|
||||
@click="onDelete"
|
||||
>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,20 +1,63 @@
|
||||
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('Token management', () => {
|
||||
describe('WhenTokensAreSet', () => {
|
||||
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
|
||||
setAuthTokens('access-abc', 'refresh-xyz');
|
||||
@@ -49,3 +92,183 @@ describe('ApiClient token management', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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('Response interceptor - 401 refresh flow', () => {
|
||||
describe('WhenARequestFailsWith401AndARefreshTokenExists', () => {
|
||||
it('ThenTheSessionIsRefreshedAndTheOriginalRequestIsRetried', async () => {
|
||||
setAuthTokens('expired-access-token', 'valid-refresh-token');
|
||||
|
||||
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('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');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<any>).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<any>).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<any>).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<any>;
|
||||
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<any>).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();
|
||||
});
|
||||
});
|
||||
});
|
||||
216
src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts
Normal file
216
src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts
Normal file
@@ -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: [] }],
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: '<div />' };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
113
src/admin/tests/unit/stores/theme.spec.ts
Normal file
113
src/admin/tests/unit/stores/theme.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/admin/tests/unit/utils/validation.spec.ts
Normal file
52
src/admin/tests/unit/utils/validation.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
84
src/admin/tests/unit/views/AcceptInviteView.spec.ts
Normal file
84
src/admin/tests/unit/views/AcceptInviteView.spec.ts
Normal file
@@ -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: '<div />' } },
|
||||
{ path: '/login', name: 'Login', component: { template: '<div />' } },
|
||||
];
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
129
src/admin/tests/unit/views/LoginView.spec.ts
Normal file
129
src/admin/tests/unit/views/LoginView.spec.ts
Normal file
@@ -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: '<div />' } },
|
||||
{ path: '/register', component: { template: '<div />' } },
|
||||
];
|
||||
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||
return { wrapper: mount(LoginView, { global: { plugins: [router] } }), router };
|
||||
}
|
||||
|
||||
async function fillAndSubmit(wrapper: ReturnType<typeof mount>, 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
155
src/admin/tests/unit/views/RegisterView.spec.ts
Normal file
155
src/admin/tests/unit/views/RegisterView.spec.ts
Normal file
@@ -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: '<div />' } },
|
||||
{ path: '/login', component: { template: '<div />' } },
|
||||
];
|
||||
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<typeof mount>, fields: Partial<RegisterFields> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<any>).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<any>).vm.$emit('update:modelValue', 'beta_users');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
|
||||
@@ -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<any>).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<any>).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<any>).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);
|
||||
|
||||
Reference in New Issue
Block a user