From 35b5934af979761de590a284edbe56bb119dc1a7 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 2 Jul 2026 12:38:55 -0700 Subject: [PATCH] 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. --- .../src/components/features/FeatureDetail.vue | 13 + src/admin/src/router/index.ts | 8 +- src/admin/tests/unit/api/client.spec.ts | 271 ++++++++++++++++-- .../segments/ConditionEditor.spec.ts | 94 ++++++ .../segments/RuleGroupEditor.spec.ts | 216 ++++++++++++++ src/admin/tests/unit/router/router.spec.ts | 94 +++--- src/admin/tests/unit/stores/theme.spec.ts | 113 ++++++++ src/admin/tests/unit/utils/validation.spec.ts | 52 ++++ .../tests/unit/views/AcceptInviteView.spec.ts | 84 ++++++ src/admin/tests/unit/views/LoginView.spec.ts | 129 +++++++++ .../tests/unit/views/RegisterView.spec.ts | 155 ++++++++++ .../tests/unit/views/SegmentsView.spec.ts | 15 +- .../tests/unit/views/SettingsView.spec.ts | 28 +- 13 files changed, 1189 insertions(+), 83 deletions(-) create mode 100644 src/admin/tests/unit/components/segments/ConditionEditor.spec.ts create mode 100644 src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts create mode 100644 src/admin/tests/unit/stores/theme.spec.ts create mode 100644 src/admin/tests/unit/utils/validation.spec.ts create mode 100644 src/admin/tests/unit/views/AcceptInviteView.spec.ts create mode 100644 src/admin/tests/unit/views/LoginView.spec.ts create mode 100644 src/admin/tests/unit/views/RegisterView.spec.ts 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);