Building out navigation in the admin site
This commit is contained in:
@@ -8,9 +8,6 @@ function makeRouter() {
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/features', component: { template: '<div />' } },
|
||||
{ path: '/environments', component: { template: '<div />' } },
|
||||
{ path: '/projects', component: { template: '<div />' } },
|
||||
{ path: '/profile', component: { template: '<div />' } },
|
||||
{ path: '/settings', component: { template: '<div />' } },
|
||||
],
|
||||
@@ -24,41 +21,21 @@ describe('AppHeader', () => {
|
||||
const router = makeRouter();
|
||||
await router.push('/');
|
||||
|
||||
// v-app-bar requires a v-app layout context — wrap the component under test
|
||||
wrapper = mount(
|
||||
{ template: '<v-app><AppHeader /></v-app>', components: { AppHeader } },
|
||||
{ global: { plugins: [router] } },
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the Features navigation link', () => {
|
||||
const link = wrapper.find('[data-testid="nav-link-features"]');
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.text()).toBe('Features');
|
||||
});
|
||||
|
||||
it('renders the Environments navigation link', () => {
|
||||
const link = wrapper.find('[data-testid="nav-link-environments"]');
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.text()).toBe('Environments');
|
||||
});
|
||||
|
||||
it('renders the Projects navigation link', () => {
|
||||
const link = wrapper.find('[data-testid="nav-link-projects"]');
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.text()).toBe('Projects');
|
||||
});
|
||||
|
||||
it('renders the SVG microphone logo image', () => {
|
||||
const logo = wrapper.find('[data-testid="app-logo"]');
|
||||
expect(logo.exists()).toBe(true);
|
||||
expect(logo.attributes('alt')).toBe('MicCheck logo');
|
||||
});
|
||||
|
||||
it('renders the profile link with John Doe text', () => {
|
||||
it('renders the profile link', () => {
|
||||
const link = wrapper.find('[data-testid="profile-link"]');
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.text()).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('renders the settings gear icon link', () => {
|
||||
|
||||
51
src/admin/tests/unit/api/client.spec.ts
Normal file
51
src/admin/tests/unit/api/client.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
|
||||
|
||||
// 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.
|
||||
|
||||
describe('ApiClient token management', () => {
|
||||
beforeEach(() => {
|
||||
clearAuthTokens();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAuthTokens();
|
||||
});
|
||||
|
||||
describe('WhenTokensAreSet', () => {
|
||||
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
|
||||
setAuthTokens('access-abc', 'refresh-xyz');
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/admin/tests/unit/components/EnvironmentTabBar.spec.ts
Normal file
150
src/admin/tests/unit/components/EnvironmentTabBar.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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 EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { ProjectResponse, EnvironmentResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/environments');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as envApi from '@/api/environments';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10,
|
||||
name: 'Website',
|
||||
organizationId: 1,
|
||||
hideDisabledFlags: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockEnvironments: EnvironmentResponse[] = [
|
||||
{ id: 100, name: 'Development', apiKey: 'env-key-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 101, name: 'Production', apiKey: 'env-key-prod', projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
function mountComponent() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
});
|
||||
return mount(EnvironmentTabBar, {
|
||||
global: { plugins: [router] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('EnvironmentTabBar', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenNoProjectIsSelected', () => {
|
||||
it('ThenEnvironmentsAreNotFetched', async () => {
|
||||
mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(envApi.listEnvironments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ThenNoProjectMessageIsShown', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="env-no-project-message"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectIsSetInContextStore', () => {
|
||||
it('ThenEnvironmentsAreFetchedForThatProject', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
mountComponent();
|
||||
|
||||
contextStore.setProject(mockProject);
|
||||
await flushPromises();
|
||||
|
||||
expect(envApi.listEnvironments).toHaveBeenCalledWith(mockProject.id);
|
||||
});
|
||||
|
||||
it('ThenEnvironmentChipsAreRendered', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
const wrapper = mountComponent();
|
||||
|
||||
contextStore.setProject(mockProject);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="env-chip-development"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="env-chip-production"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenFirstEnvironmentIsAutoSelected', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
mountComponent();
|
||||
|
||||
contextStore.setProject(mockProject);
|
||||
await flushPromises();
|
||||
|
||||
expect(contextStore.currentEnvironment).toEqual(mockEnvironments[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenEnvironmentChipIsClicked', () => {
|
||||
it('ThenContextStoreIsUpdatedWithSelectedEnvironment', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
const wrapper = mountComponent();
|
||||
|
||||
contextStore.setProject(mockProject);
|
||||
await flushPromises();
|
||||
|
||||
// Simulate chip-group selecting index 1 (Production)
|
||||
await wrapper.findComponent({ name: 'VChipGroup' }).vm.$emit('update:modelValue', 1);
|
||||
|
||||
expect(contextStore.currentEnvironment).toEqual(mockEnvironments[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectChanges', () => {
|
||||
it('ThenEnvironmentsAreReloaded', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
mountComponent();
|
||||
|
||||
contextStore.setProject(mockProject);
|
||||
await flushPromises();
|
||||
|
||||
const newProject: ProjectResponse = { ...mockProject, id: 20, name: 'Mobile' };
|
||||
contextStore.setProject(newProject);
|
||||
await flushPromises();
|
||||
|
||||
expect(envApi.listEnvironments).toHaveBeenCalledTimes(2);
|
||||
expect(envApi.listEnvironments).toHaveBeenLastCalledWith(newProject.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenApiReturnsNoEnvironments', () => {
|
||||
it('ThenEmptyMessageIsDisplayed', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
const wrapper = mountComponent();
|
||||
|
||||
contextStore.setProject(mockProject);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="env-empty-message"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/admin/tests/unit/components/OrgSelector.spec.ts
Normal file
98
src/admin/tests/unit/components/OrgSelector.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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 OrgSelector from '@/components/nav/OrgSelector.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { OrganizationResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/organizations');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as orgsApi from '@/api/organizations';
|
||||
|
||||
const mockOrgs: OrganizationResponse[] = [
|
||||
{ id: 1, name: 'Acme Corp', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
function mountComponent() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
});
|
||||
return mount(OrgSelector, {
|
||||
global: { plugins: [router] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('OrgSelector', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenComponentMounts', () => {
|
||||
it('ThenOrganizationsAreFetchedFromTheApi', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs);
|
||||
|
||||
mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(orgsApi.listOrganizations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ThenOrganizationsAreRenderedInTheSelect', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const select = wrapper.find('[data-testid="org-select"]');
|
||||
expect(select.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOrganizationIsSelected', () => {
|
||||
it('ThenContextStoreIsUpdated', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const contextStore = useContextStore();
|
||||
// Simulate the v-select emitting an update
|
||||
await wrapper.findComponent({ name: 'VSelect' }).vm.$emit('update:modelValue', mockOrgs[0]);
|
||||
|
||||
expect(contextStore.currentOrganization).toEqual(mockOrgs[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenApiReturnsNoOrganizations', () => {
|
||||
it('ThenEmptyMessageIsDisplayed', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const emptyMsg = wrapper.find('[data-testid="org-empty-message"]');
|
||||
expect(emptyMsg.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenApiCallFails', () => {
|
||||
it('ThenOrganizationListRemainsEmpty', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
// No crash — component stays rendered
|
||||
expect(wrapper.find('[data-testid="org-selector"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/admin/tests/unit/components/ProjectSelector.spec.ts
Normal file
98
src/admin/tests/unit/components/ProjectSelector.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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 ProjectSelector from '@/components/nav/ProjectSelector.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { OrganizationResponse, ProjectResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/projects');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as projectsApi from '@/api/projects';
|
||||
|
||||
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' };
|
||||
const mockProjects: ProjectResponse[] = [
|
||||
{ id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 11, name: 'Mobile App', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
function mountComponent() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
});
|
||||
return mount(ProjectSelector, {
|
||||
global: { plugins: [router] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('ProjectSelector', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenNoOrganizationIsSelected', () => {
|
||||
it('ThenProjectsAreNotFetched', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.listProjects).not.toHaveBeenCalled();
|
||||
expect(wrapper.find('[data-testid="project-selector"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOrganizationIsSetInContextStore', () => {
|
||||
it('ThenProjectsAreFetchedForThatOrganization', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
mountComponent();
|
||||
|
||||
contextStore.setOrganization(mockOrg);
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.listProjects).toHaveBeenCalledWith(mockOrg.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectIsSelected', () => {
|
||||
it('ThenContextStoreIsUpdated', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.findComponent({ name: 'VSelect' }).vm.$emit('update:modelValue', mockProjects[0]);
|
||||
|
||||
expect(contextStore.currentProject).toEqual(mockProjects[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOrganizationChanges', () => {
|
||||
it('ThenProjectsAreReloaded', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
mountComponent();
|
||||
|
||||
contextStore.setOrganization(mockOrg);
|
||||
await flushPromises();
|
||||
|
||||
const newOrg: OrganizationResponse = { id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' };
|
||||
contextStore.setOrganization(newOrg);
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.listProjects).toHaveBeenCalledTimes(2);
|
||||
expect(projectsApi.listProjects).toHaveBeenLastCalledWith(newOrg.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
112
src/admin/tests/unit/router/router.spec.ts
Normal file
112
src/admin/tests/unit/router/router.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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';
|
||||
|
||||
// Minimal stub components for route testing
|
||||
const Stub = { template: '<div />' };
|
||||
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import { getAccessToken } from '@/api/client';
|
||||
|
||||
function buildRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'Login', component: Stub, meta: { public: true } },
|
||||
{ path: '/register', name: 'Register', component: Stub, meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: Stub,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'Dashboard', component: Stub },
|
||||
{ path: 'features', name: 'Features', component: Stub },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('Router auth guard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAuthTokens();
|
||||
});
|
||||
|
||||
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
|
||||
it('ThenTheyAreRedirectedToLogin', 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');
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/features');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenAuthenticatedUserNavigatesToLogin', () => {
|
||||
it('ThenTheyAreRedirectedToDashboard', async () => {
|
||||
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');
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('Dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenAuthenticatedUserNavigatesToProtectedRoute', () => {
|
||||
it('ThenNavigationSucceeds', async () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
172
src/admin/tests/unit/stores/auth.spec.ts
Normal file
172
src/admin/tests/unit/stores/auth.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import * as authApi from '@/api/auth';
|
||||
import * as clientModule from '@/api/client';
|
||||
|
||||
jest.mock('@/api/auth');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockTokenResponse = {
|
||||
accessToken: 'access-token-123',
|
||||
refreshToken: 'refresh-token-456',
|
||||
expiresAt: '2026-12-31T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenUserLogsInWithValidCredentials', () => {
|
||||
it('ThenTokensAreStoredInStateAndLocalStorage', async () => {
|
||||
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
|
||||
|
||||
const store = useAuthStore();
|
||||
await store.login('user@example.com', 'password123');
|
||||
|
||||
expect(store.isAuthenticated).toBe(true);
|
||||
expect(store.accessToken).toBe(mockTokenResponse.accessToken);
|
||||
expect(store.refreshToken).toBe(mockTokenResponse.refreshToken);
|
||||
expect(store.expiresAt).toBe(mockTokenResponse.expiresAt);
|
||||
expect(localStorage.getItem('mic_access_token')).toBe(mockTokenResponse.accessToken);
|
||||
expect(localStorage.getItem('mic_refresh_token')).toBe(mockTokenResponse.refreshToken);
|
||||
expect(localStorage.getItem('mic_expires_at')).toBe(mockTokenResponse.expiresAt);
|
||||
});
|
||||
|
||||
it('ThenApiClientTokensAreUpdated', async () => {
|
||||
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
|
||||
|
||||
const store = useAuthStore();
|
||||
await store.login('user@example.com', 'password123');
|
||||
|
||||
expect(clientModule.setAuthTokens).toHaveBeenCalledWith(
|
||||
mockTokenResponse.accessToken,
|
||||
mockTokenResponse.refreshToken,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenUserLogsInWithInvalidCredentials', () => {
|
||||
it('ThenAuthStateRemainsUnauthenticated', async () => {
|
||||
jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
|
||||
|
||||
const store = useAuthStore();
|
||||
await expect(store.login('bad@example.com', 'wrong')).rejects.toThrow('Unauthorized');
|
||||
|
||||
expect(store.isAuthenticated).toBe(false);
|
||||
expect(store.accessToken).toBeNull();
|
||||
expect(localStorage.getItem('mic_access_token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenUserRegistersSuccessfully', () => {
|
||||
it('ThenTokensAreStoredAndUserIsAuthenticated', async () => {
|
||||
jest.mocked(authApi.register).mockResolvedValue(mockTokenResponse);
|
||||
|
||||
const store = useAuthStore();
|
||||
await store.register('user@example.com', 'password123', 'Jane', 'Doe', 'Acme Corp');
|
||||
|
||||
expect(store.isAuthenticated).toBe(true);
|
||||
expect(store.accessToken).toBe(mockTokenResponse.accessToken);
|
||||
});
|
||||
|
||||
it('ThenRegisterApiIsCalledWithCorrectPayload', async () => {
|
||||
jest.mocked(authApi.register).mockResolvedValue(mockTokenResponse);
|
||||
|
||||
const store = useAuthStore();
|
||||
await store.register('user@example.com', 'password123', 'Jane', 'Doe', 'Acme Corp');
|
||||
|
||||
expect(authApi.register).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
organizationName: 'Acme Corp',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenUserLogsOut', () => {
|
||||
it('ThenTokensAreRemovedFromStateAndLocalStorage', async () => {
|
||||
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
|
||||
jest.mocked(authApi.logout).mockResolvedValue(undefined);
|
||||
|
||||
const store = useAuthStore();
|
||||
await store.login('user@example.com', 'password123');
|
||||
await store.logout();
|
||||
|
||||
expect(store.isAuthenticated).toBe(false);
|
||||
expect(store.accessToken).toBeNull();
|
||||
expect(store.refreshToken).toBeNull();
|
||||
expect(localStorage.getItem('mic_access_token')).toBeNull();
|
||||
expect(localStorage.getItem('mic_refresh_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('ThenApiClientTokensAreCleared', async () => {
|
||||
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
|
||||
jest.mocked(authApi.logout).mockResolvedValue(undefined);
|
||||
|
||||
const store = useAuthStore();
|
||||
await store.login('user@example.com', 'password123');
|
||||
jest.clearAllMocks();
|
||||
|
||||
await store.logout();
|
||||
|
||||
expect(clientModule.clearAuthTokens).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ThenLocalLogoutCompletesEvenWhenServerCallFails', async () => {
|
||||
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
|
||||
jest.mocked(authApi.logout).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const store = useAuthStore();
|
||||
await store.login('user@example.com', 'password123');
|
||||
await store.logout();
|
||||
|
||||
expect(store.isAuthenticated).toBe(false);
|
||||
expect(localStorage.getItem('mic_access_token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenLoadFromStorageIsCalledWithSavedTokens', () => {
|
||||
it('ThenAuthStateIsRestoredFromLocalStorage', () => {
|
||||
localStorage.setItem('mic_access_token', 'stored-access');
|
||||
localStorage.setItem('mic_refresh_token', 'stored-refresh');
|
||||
localStorage.setItem('mic_expires_at', '2026-12-31T00:00:00Z');
|
||||
|
||||
const store = useAuthStore();
|
||||
store.loadFromStorage();
|
||||
|
||||
expect(store.isAuthenticated).toBe(true);
|
||||
expect(store.accessToken).toBe('stored-access');
|
||||
expect(store.refreshToken).toBe('stored-refresh');
|
||||
});
|
||||
|
||||
it('ThenApiClientTokensAreSet', () => {
|
||||
localStorage.setItem('mic_access_token', 'stored-access');
|
||||
localStorage.setItem('mic_refresh_token', 'stored-refresh');
|
||||
|
||||
const store = useAuthStore();
|
||||
store.loadFromStorage();
|
||||
|
||||
expect(clientModule.setAuthTokens).toHaveBeenCalledWith('stored-access', 'stored-refresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenLoadFromStorageIsCalledWithNoSavedTokens', () => {
|
||||
it('ThenUserRemainsUnauthenticated', () => {
|
||||
const store = useAuthStore();
|
||||
store.loadFromStorage();
|
||||
|
||||
expect(store.isAuthenticated).toBe(false);
|
||||
expect(store.accessToken).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/admin/tests/unit/stores/context.spec.ts
Normal file
137
src/admin/tests/unit/stores/context.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { OrganizationResponse, ProjectResponse, EnvironmentResponse } from '@/types/api';
|
||||
|
||||
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' };
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10,
|
||||
name: 'Website',
|
||||
organizationId: 1,
|
||||
hideDisabledFlags: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockEnv: EnvironmentResponse = {
|
||||
id: 100,
|
||||
name: 'Production',
|
||||
apiKey: 'env-key-abc',
|
||||
projectId: 10,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('useContextStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('WhenOrganizationIsSelected', () => {
|
||||
it('ThenCurrentOrganizationIsUpdated', () => {
|
||||
const store = useContextStore();
|
||||
store.setOrganization(mockOrg);
|
||||
|
||||
expect(store.currentOrganization).toEqual(mockOrg);
|
||||
});
|
||||
|
||||
it('ThenOrganizationIsPersistedToLocalStorage', () => {
|
||||
const store = useContextStore();
|
||||
store.setOrganization(mockOrg);
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('mic_ctx_organization')!);
|
||||
expect(stored).toEqual(mockOrg);
|
||||
});
|
||||
|
||||
it('ThenProjectAndEnvironmentAreReset', () => {
|
||||
const store = useContextStore();
|
||||
store.setOrganization(mockOrg);
|
||||
store.setProject(mockProject);
|
||||
store.setEnvironment(mockEnv);
|
||||
|
||||
store.setOrganization({ ...mockOrg, id: 2 });
|
||||
|
||||
expect(store.currentProject).toBeNull();
|
||||
expect(store.currentEnvironment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectIsSelected', () => {
|
||||
it('ThenCurrentProjectIsUpdated', () => {
|
||||
const store = useContextStore();
|
||||
store.setOrganization(mockOrg);
|
||||
store.setProject(mockProject);
|
||||
|
||||
expect(store.currentProject).toEqual(mockProject);
|
||||
});
|
||||
|
||||
it('ThenEnvironmentIsReset', () => {
|
||||
const store = useContextStore();
|
||||
store.setOrganization(mockOrg);
|
||||
store.setProject(mockProject);
|
||||
store.setEnvironment(mockEnv);
|
||||
|
||||
store.setProject({ ...mockProject, id: 11 });
|
||||
|
||||
expect(store.currentEnvironment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenEnvironmentIsSelected', () => {
|
||||
it('ThenCurrentEnvironmentIsUpdated', () => {
|
||||
const store = useContextStore();
|
||||
store.setEnvironment(mockEnv);
|
||||
|
||||
expect(store.currentEnvironment).toEqual(mockEnv);
|
||||
});
|
||||
|
||||
it('ThenEnvironmentIsPersistedToLocalStorage', () => {
|
||||
const store = useContextStore();
|
||||
store.setEnvironment(mockEnv);
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('mic_ctx_environment')!);
|
||||
expect(stored).toEqual(mockEnv);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenLoadFromStorageIsCalledWithSavedContext', () => {
|
||||
it('ThenContextIsRestoredFromLocalStorage', () => {
|
||||
localStorage.setItem('mic_ctx_organization', JSON.stringify(mockOrg));
|
||||
localStorage.setItem('mic_ctx_project', JSON.stringify(mockProject));
|
||||
localStorage.setItem('mic_ctx_environment', JSON.stringify(mockEnv));
|
||||
|
||||
const store = useContextStore();
|
||||
store.loadFromStorage();
|
||||
|
||||
expect(store.currentOrganization).toEqual(mockOrg);
|
||||
expect(store.currentProject).toEqual(mockProject);
|
||||
expect(store.currentEnvironment).toEqual(mockEnv);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenLoadFromStorageEncountersCorruptData', () => {
|
||||
it('ThenContextRemainsNullAndStorageIsCleared', () => {
|
||||
localStorage.setItem('mic_ctx_organization', 'not-valid-json{{');
|
||||
|
||||
const store = useContextStore();
|
||||
store.loadFromStorage();
|
||||
|
||||
expect(store.currentOrganization).toBeNull();
|
||||
expect(localStorage.getItem('mic_ctx_organization')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenClearContextIsCalled', () => {
|
||||
it('ThenAllContextIsRemovedFromStateAndStorage', () => {
|
||||
const store = useContextStore();
|
||||
store.setOrganization(mockOrg);
|
||||
store.setProject(mockProject);
|
||||
store.setEnvironment(mockEnv);
|
||||
|
||||
store.clearContext();
|
||||
|
||||
expect(store.currentOrganization).toBeNull();
|
||||
expect(store.currentProject).toBeNull();
|
||||
expect(store.currentEnvironment).toBeNull();
|
||||
expect(localStorage.getItem('mic_ctx_organization')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user