Building out navigation in the admin site

This commit is contained in:
2026-04-13 15:27:09 -07:00
parent 334b6cf3e1
commit f2816b6900
67 changed files with 3426 additions and 236 deletions

View File

@@ -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', () => {

View 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');
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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');
});
});
});

View 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();
});
});
});

View 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();
});
});
});