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

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