diff --git a/src/modules/organizations/tests/organizations.domainJoin.e2e.tests.js b/src/modules/organizations/tests/organizations.domainJoin.e2e.tests.js index dcbca16a2..4845d2514 100644 --- a/src/modules/organizations/tests/organizations.domainJoin.e2e.tests.js +++ b/src/modules/organizations/tests/organizations.domainJoin.e2e.tests.js @@ -297,12 +297,12 @@ test.describe('Organization Domain Join E2E', () => { test('approved member — no Manage button on account page', async ({ page }) => { test.skip(!orgId, 'Setup was skipped — no org created'); await signin(page, memberEmail, password); - await page.goto('/users'); - await page.waitForLoadState('domcontentloaded'); - - // Click the Organizations tab - const orgTab = page.getByRole('tab', { name: /organizations/i }); - await orgTab.click({ timeout: 10000 }); + // Gamma refactor: Organizations is its own routed view at /users/organizations + // (no longer a tab inside /users) — navigate directly instead of clicking a tab. + // networkidle (vs domcontentloaded) waits for the fetchOrganizations() XHR + // initiated in the view's created() hook to complete before assertions. + await page.goto('/users/organizations'); + await page.waitForLoadState('networkidle'); // Wait for the domain org list item to appear const domainOrgItem = page.locator('.v-list-item', { hasText: `DomainOrg${timestamp}` }); diff --git a/src/modules/users/config/users.development.config.js b/src/modules/users/config/users.development.config.js index 75d62d04f..48f891244 100644 --- a/src/modules/users/config/users.development.config.js +++ b/src/modules/users/config/users.development.config.js @@ -4,4 +4,10 @@ export default { roles: ['user', 'admin'], }, }, + users: { + tabs: [ + { value: 'profile', label: 'Profile', icon: 'fa-solid fa-id-card', route: 'profile' }, + { value: 'organizations', label: 'Organizations', icon: 'fa-solid fa-building', route: 'organizations' }, + ], + }, }; diff --git a/src/modules/users/router/users.router.js b/src/modules/users/router/users.router.js index d3fb600e6..a664c6bdf 100644 --- a/src/modules/users/router/users.router.js +++ b/src/modules/users/router/users.router.js @@ -46,6 +46,31 @@ export default [ position: 'bottom', requiresAuth: true, }, + children: [ + { + // bare /users → /users/profile (default child) + path: '', + redirect: { name: 'Account Profile' }, + }, + { + path: 'profile', + name: 'Account Profile', + component: () => import('../views/user.profile.view.vue'), + meta: { + display: false, + requiresAuth: true, + }, + }, + { + path: 'organizations', + name: 'Account Organizations', + component: () => import('../views/user.organizations.view.vue'), + meta: { + display: false, + requiresAuth: true, + }, + }, + ], }, { path: '/users/:id', diff --git a/src/modules/users/tests/user.organizations.view.unit.tests.js b/src/modules/users/tests/user.organizations.view.unit.tests.js new file mode 100644 index 000000000..e81d709a2 --- /dev/null +++ b/src/modules/users/tests/user.organizations.view.unit.tests.js @@ -0,0 +1,130 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import UserOrganizationsView from '../views/user.organizations.view.vue'; + +// Mock config service +vi.mock('../../../lib/services/config', () => ({ + default: { + api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, + cookie: { prefix: 'devkit' }, + }, +})); + +vi.mock('../../../lib/helpers/ability', () => ({ updateAbilities: vi.fn() })); +vi.mock('../../../lib/helpers/roleColor', () => ({ default: () => 'primary' })); +vi.mock('../../../lib/helpers/orgColor', () => ({ default: () => 'blue' })); + +const sharedStubs = { + orgAvatarComponent: { template: '
' }, + 'v-container': { template: '
' }, + 'v-list': { template: '
' }, + 'v-list-item': { template: '
' }, + 'v-list-item-title': { template: '
' }, + 'v-list-item-subtitle': { template: '
' }, + 'v-divider': { template: '
' }, + 'v-chip': { template: '
' }, + 'v-btn': { template: '', inheritAttrs: false }, + 'v-icon': { template: '
' }, + 'v-dialog': { template: '
' }, + 'v-card': { template: '
' }, + 'v-card-title': { template: '
' }, + 'v-card-text': { template: '
' }, + 'v-card-actions': { template: '
' }, + 'v-spacer': { template: '
' }, +}; + +const sharedMocks = ($router = { push: vi.fn() }) => ({ + $router, + $route: { path: '/users/organizations' }, + config: { + api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, + vuetify: { theme: { rounded: 'rounded-lg', flat: true } }, + }, +}); + +describe('user.organizations.view', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + test('renders the new-org button with data-test="users-orgs-new"', async () => { + const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); + const store = useOrganizationsStore(); + store.fetchOrganizations = vi.fn().mockResolvedValue([]); + + const wrapper = shallowMount(UserOrganizationsView, { + global: { + mocks: sharedMocks(), + stubs: sharedStubs, + }, + }); + + expect(wrapper.find('[data-test="users-orgs-new"]').exists()).toBe(true); + }); + + test('leaveDialog defaults to false', async () => { + const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); + const store = useOrganizationsStore(); + store.fetchOrganizations = vi.fn().mockResolvedValue([]); + + const wrapper = shallowMount(UserOrganizationsView, { + global: { + mocks: sharedMocks(), + stubs: sharedStubs, + }, + }); + + expect(wrapper.vm.leaveDialog).toBe(false); + }); + + test('confirmLeave sets orgToLeave and opens leaveDialog', async () => { + const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); + const store = useOrganizationsStore(); + store.fetchOrganizations = vi.fn().mockResolvedValue([]); + + const wrapper = shallowMount(UserOrganizationsView, { + global: { + mocks: sharedMocks(), + stubs: sharedStubs, + }, + }); + + const org = { id: 'org-1', name: 'Test Org', role: 'member' }; + wrapper.vm.confirmLeave(org); + + expect(wrapper.vm.orgToLeave).toEqual(org); + expect(wrapper.vm.leaveDialog).toBe(true); + }); + + test('leaveOrg redirects to /organization-required when no orgs remain', async () => { + const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); + const { useAuthStore } = await import('../../auth/stores/auth.store'); + const store = useOrganizationsStore(); + const authStore = useAuthStore(); + + store.fetchOrganizations = vi.fn().mockResolvedValue([]); + const routerPush = vi.fn(); + + const wrapper = shallowMount(UserOrganizationsView, { + global: { + mocks: sharedMocks({ push: routerPush }), + stubs: sharedStubs, + }, + }); + + const orgId = 'org-1'; + wrapper.vm.orgToLeave = { id: orgId, name: 'Only Org' }; + + store.leaveOrganization = vi.fn().mockImplementation(() => { + store.organizations = []; + return Promise.resolve(); + }); + authStore.refreshAbilities = vi.fn().mockResolvedValue(); + + await wrapper.vm.leaveOrg(); + + expect(store.leaveOrganization).toHaveBeenCalledWith(orgId); + expect(routerPush).toHaveBeenCalledWith('/organization-required'); + }); +}); diff --git a/src/modules/users/tests/user.profile.view.unit.tests.js b/src/modules/users/tests/user.profile.view.unit.tests.js new file mode 100644 index 000000000..b1aed3f32 --- /dev/null +++ b/src/modules/users/tests/user.profile.view.unit.tests.js @@ -0,0 +1,125 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import UserProfileView from '../views/user.profile.view.vue'; + +// Mock axios +vi.mock('../../../lib/services/axios', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +// Mock config service +vi.mock('../../../lib/services/config', () => ({ + default: { + api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, + cookie: { prefix: 'devkit' }, + }, +})); + +vi.mock('../../../lib/helpers/ability', () => ({ updateAbilities: vi.fn() })); + +const sharedStubs = { + userProfileComponent: { template: '
', name: 'UserProfileComponent' }, + 'v-container': { template: '
' }, + 'v-card': { template: '
' }, + 'v-card-title': { template: '
' }, + 'v-card-text': { template: '
' }, + 'v-card-actions': { template: '
' }, + 'v-btn': { template: '' }, + 'v-dialog': { template: '
' }, + 'v-text-field': { template: '
' }, + 'v-spacer': { template: '
' }, +}; + +const sharedMocks = ($router = { push: vi.fn() }) => ({ + $router, + $route: { path: '/users/profile' }, + config: { + api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, + vuetify: { theme: { rounded: 'rounded-lg', flat: true } }, + }, +}); + +describe('user.profile.view', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + test('renders the userProfileComponent', () => { + const wrapper = shallowMount(UserProfileView, { + global: { + mocks: sharedMocks(), + stubs: sharedStubs, + }, + }); + expect(wrapper.findComponent({ name: 'UserProfileComponent' }).exists()).toBe(true); + }); + + test('renders the danger zone Delete Account card', () => { + const wrapper = shallowMount(UserProfileView, { + global: { + mocks: sharedMocks(), + stubs: sharedStubs, + }, + }); + expect(wrapper.html()).toContain('Delete Account'); + }); + + test('confirmDeleteAccount defaults to false', () => { + const wrapper = shallowMount(UserProfileView, { + global: { + mocks: sharedMocks(), + stubs: sharedStubs, + }, + }); + expect(wrapper.vm.confirmDeleteAccount).toBe(false); + }); + + test('deleteAccount method calls axios.delete and redirects to /signin on success', async () => { + const axios = (await import('../../../lib/services/axios')).default; + const routerPush = vi.fn(); + + const wrapper = shallowMount(UserProfileView, { + global: { + mocks: sharedMocks({ push: routerPush }), + stubs: sharedStubs, + }, + }); + + const { useAuthStore } = await import('../../auth/stores/auth.store'); + const authStore = useAuthStore(); + authStore.signout = vi.fn().mockResolvedValue(); + axios.delete.mockResolvedValue({}); + + await wrapper.vm.deleteAccount(); + + expect(axios.delete).toHaveBeenCalledWith(expect.stringContaining('/users')); + expect(authStore.signout).toHaveBeenCalled(); + expect(routerPush).toHaveBeenCalledWith('/signin'); + }); + + test('deleteAccount closes dialog on error (swallows exception)', async () => { + const axios = (await import('../../../lib/services/axios')).default; + + const wrapper = shallowMount(UserProfileView, { + global: { + mocks: sharedMocks(), + stubs: sharedStubs, + }, + }); + + wrapper.vm.confirmDeleteAccount = true; + wrapper.vm.deleteConfirmInput = 'DELETE'; + axios.delete.mockRejectedValue(new Error('Server error')); + + await wrapper.vm.deleteAccount(); + + expect(wrapper.vm.confirmDeleteAccount).toBe(false); + expect(wrapper.vm.deleteConfirmInput).toBe(''); + }); +}); diff --git a/src/modules/users/tests/user.view.unit.tests.js b/src/modules/users/tests/user.view.unit.tests.js index 582940785..24793a57e 100644 --- a/src/modules/users/tests/user.view.unit.tests.js +++ b/src/modules/users/tests/user.view.unit.tests.js @@ -2,21 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; import { shallowMount } from '@vue/test-utils'; import { useOrganizationsStore } from '../../organizations/stores/organizations.store'; -import { useAuthStore } from '../../auth/stores/auth.store'; import UserView from '../views/user.view.vue'; -import axios from '../../../lib/services/axios'; -// Mock axios -vi.mock('../../../lib/services/axios', () => ({ - default: { - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn(), - }, -})); - -// Mock config +// Mock config service vi.mock('../../../lib/services/config', () => ({ default: { api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, @@ -24,489 +12,153 @@ vi.mock('../../../lib/services/config', () => ({ }, })); -// Mock helpers -vi.mock('../../../lib/helpers/roleColor', () => ({ default: () => 'primary' })); -vi.mock('../../../lib/helpers/orgColor', () => ({ default: () => 'blue' })); -vi.mock('../../../lib/helpers/ability', () => ({ updateAbilities: vi.fn() })); +vi.mock('../../../lib/helpers/ability', () => ({ ability: null, updateAbilities: vi.fn() })); const sharedStubs = { - PageHeader: true, - userProfileComponent: true, - organizationsSwitcherComponent: true, - orgAvatarComponent: true, + PageHeader: { template: '
', name: 'PageHeader' }, + CoreSurfaceTabBar: { template: '
', name: 'CoreSurfaceTabBar', props: ['tabs', 'can', 'basePath'] }, + organizationsSwitcherComponent: { template: '
' }, + RouterView: { template: '
' }, + 'router-view': { template: '
' }, 'v-container': { template: '
' }, - 'v-row': { template: '
' }, - 'v-col': { template: '
' }, - 'v-card': { template: '
' }, - 'v-tabs': { template: '
' }, - 'v-tab': { template: '
' }, - 'v-divider': { template: '
' }, - 'v-window': { template: '
' }, - 'v-window-item': { template: '
' }, - 'v-list': { template: '
' }, - 'v-list-item': { template: '
' }, - 'v-list-item-title': { template: '
' }, - 'v-list-item-subtitle': { template: '
' }, - 'v-avatar': { template: '
' }, - 'v-chip': { template: '
' }, - 'v-btn': { template: '
' }, - 'v-icon': { template: '
' }, - 'v-dialog': { template: '
' }, - 'v-card-title': { template: '
' }, - 'v-card-text': { template: '
' }, - 'v-card-actions': { template: '
' }, - 'v-spacer': { template: '
' }, - 'v-text-field': { template: '
' }, }; -const sharedMocks = ($router = { push: vi.fn() }, $route = { query: {}, hash: '' }) => ({ - $router, - $route, - $t: (key) => key, +const sharedMocks = () => ({ config: { api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, vuetify: { theme: { flat: true, rounded: 'rounded' } }, + users: { + tabs: [ + { value: 'profile', label: 'Profile', icon: 'fa-solid fa-id-card', route: 'profile' }, + { value: 'organizations', label: 'Organizations', icon: 'fa-solid fa-building', route: 'organizations' }, + ], + }, }, }); -describe('UserView – leaveOrg redirect behaviour', () => { - let organizationsStore; - let authStore; - let routerPush; - let wrapper; - - beforeEach(() => { - setActivePinia(createPinia()); - organizationsStore = useOrganizationsStore(); - authStore = useAuthStore(); - - // Stub fetchOrganizations so the created() hook does not hit the network - organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]); - - routerPush = vi.fn(); - - wrapper = shallowMount(UserView, { - global: { - mocks: sharedMocks({ push: routerPush }), - stubs: sharedStubs, - }, - }); - }); - - it('should redirect to /organization-required when 0 orgs remain after leaving', async () => { - // Set up: user is leaving the only org they belong to - const orgId = 'org-1'; - wrapper.vm.orgToLeave = { id: orgId, name: 'Only Org' }; - - // Stub leaveOrganization so that after call, organizations is empty - organizationsStore.leaveOrganization = vi.fn().mockImplementation(() => { - organizationsStore.organizations = []; - return Promise.resolve(); - }); - authStore.refreshAbilities = vi.fn().mockResolvedValue(); - - await wrapper.vm.leaveOrg(); - - expect(organizationsStore.leaveOrganization).toHaveBeenCalledWith(orgId); - expect(routerPush).toHaveBeenCalledWith('/organization-required'); - }); - - it('should call switchOrganization on first remaining org when currentOrganization is null after leaving', async () => { - const orgId = 'org-leave'; - const remainingOrg = { id: 'org-remain', name: 'Remaining Org' }; - wrapper.vm.orgToLeave = { id: orgId, name: 'Leaving Org' }; - - // After leaving, one org remains but currentOrganization is null - organizationsStore.leaveOrganization = vi.fn().mockImplementation(() => { - organizationsStore.organizations = [remainingOrg]; - organizationsStore.currentOrganization = null; - return Promise.resolve(); - }); - organizationsStore.switchOrganization = vi.fn().mockResolvedValue(); - authStore.refreshAbilities = vi.fn().mockResolvedValue(); - - await wrapper.vm.leaveOrg(); - - expect(organizationsStore.leaveOrganization).toHaveBeenCalledWith(orgId); - expect(organizationsStore.switchOrganization).toHaveBeenCalledWith(remainingOrg.id); - expect(routerPush).not.toHaveBeenCalled(); - }); -}); - -// ── UserView – C4 decoupling: no billing / subscriptions tab ───────────────── +// ── UserView – layout shape (Gamma refactor) ────────────────────────────────── -describe('UserView – C4 decoupling: billing tab removed', () => { +describe('UserView – layout shape (PageHeader + SurfaceTabBar + router-view)', () => { beforeEach(() => { setActivePinia(createPinia()); - const organizationsStore = useOrganizationsStore(); - organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]); }); - it('does not expose showSubscriptionsTab computed', () => { + it('renders PageHeader', () => { const wrapper = shallowMount(UserView, { global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - expect(wrapper.vm.showSubscriptionsTab).toBeUndefined(); + expect(wrapper.find('[data-test="page-header"]').exists()).toBe(true); }); - it('does not expose hasOwnerOrAdminRole computed', () => { + it('renders CoreSurfaceTabBar', () => { const wrapper = shallowMount(UserView, { global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - expect(wrapper.vm.hasOwnerOrAdminRole).toBeUndefined(); - }); - - it('does not render a subscriptions tab in the template', () => { - const authStore = useAuthStore(); - const organizationsStore = useOrganizationsStore(); - authStore.serverConfig = { billing: { enabled: true } }; - organizationsStore.organizations = [{ id: 'o1', role: 'owner', name: 'Acme' }]; - - // Use a v-tab stub that captures its slot content so we can check for absence - const tabContents = []; - const vTabStub = { - template: '
', - created() { tabContents.push(this.$slots?.default?.()?.[0]?.children || ''); }, - }; - - shallowMount(UserView, { - global: { - mocks: sharedMocks(), - stubs: { ...sharedStubs, 'v-tab': vTabStub }, - }, - }); - - // No tab content should contain 'Subscriptions' or 'subscriptions' - const allContent = tabContents.join('').toLowerCase(); - expect(allContent).not.toContain('subscriptions'); + expect(wrapper.find('[data-test="core-surface-tab-bar"]').exists()).toBe(true); }); - it('does not import or render BillingSubscriptionsComponent', () => { - // Register a sentinel stub — if user.view still imports and renders it, it would appear + it('renders a router-view for child route content', () => { const wrapper = shallowMount(UserView, { - global: { - mocks: sharedMocks(), - stubs: { - ...sharedStubs, - BillingSubscriptionsComponent: { template: '
' }, - }, - }, + global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - expect(wrapper.html()).not.toContain('data-billing-sentinel'); + expect(wrapper.find('[data-test="router-view"]').exists()).toBe(true); }); - it('only exposes profile and organizations tabs', () => { - // After C1.2 refactor, tabs are declared via tabsConfig (consumed by PageTabs). - // v-tab elements no longer appear directly in user.view.vue, so we inspect - // tabsConfig to verify the exposed set of tabs. + it('passes config.users.tabs to CoreSurfaceTabBar', () => { const wrapper = shallowMount(UserView, { - global: { - mocks: sharedMocks(), - stubs: sharedStubs, - }, - }); - - const tabValues = wrapper.vm.tabsConfig.map((t) => t.value); - expect(tabValues).toContain('profile'); - expect(tabValues).toContain('organizations'); - expect(tabValues).not.toContain('subscriptions'); - }); -}); - -// ── UserView – tab routing from query/hash ─────────────────────────────────── - -describe('UserView – tab routing from query/hash', () => { - let authStore; - let organizationsStore; - - beforeEach(() => { - setActivePinia(createPinia()); - authStore = useAuthStore(); - organizationsStore = useOrganizationsStore(); - organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]); - }); - - /** - * @desc Mount with explicit $route — caller controls query/hash. - * @param {Object} route - * @returns {import('@vue/test-utils').VueWrapper} - */ - const mountWithRoute = (route) => - shallowMount(UserView, { - global: { - mocks: sharedMocks({ push: vi.fn() }, route), - stubs: sharedStubs, - }, + global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - - it('switches to organizations tab when ?tab=organizations', async () => { - authStore.serverConfig = { billing: { enabled: true } }; - organizationsStore.organizations = [{ id: 'o1', role: 'owner' }]; - - const wrapper = mountWithRoute({ query: { tab: 'organizations' }, hash: '' }); - expect(wrapper.vm.tab).toBe('organizations'); - }); - - it('keeps default profile tab when neither query nor hash is set', async () => { - authStore.serverConfig = { billing: { enabled: true } }; - organizationsStore.organizations = [{ id: 'o1', role: 'owner' }]; - - const wrapper = mountWithRoute({ query: {}, hash: '' }); - expect(wrapper.vm.tab).toBe('profile'); - }); - - it('ignores ?tab=subscriptions (subscriptions tab removed) and stays on profile', async () => { - // After C4, there is no subscriptions tab — the request must be silently ignored - authStore.serverConfig = { billing: { enabled: true } }; - organizationsStore.organizations = [{ id: 'o1', role: 'owner' }]; - - const wrapper = mountWithRoute({ query: { tab: 'subscriptions' }, hash: '' }); - expect(wrapper.vm.tab).toBe('profile'); + const tabBar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); + const tabs = tabBar.props('tabs'); + expect(Array.isArray(tabs)).toBe(true); + expect(tabs.map((t) => t.value)).toContain('profile'); + expect(tabs.map((t) => t.value)).toContain('organizations'); }); - it('switches to profile tab when ?tab=profile', async () => { - const wrapper = mountWithRoute({ query: { tab: 'profile' }, hash: '' }); - expect(wrapper.vm.tab).toBe('profile'); - }); -}); - -// ── UserView – organizations refetch on auth state change ──────────────────── - -describe('UserView – organizations refetch on auth state change', () => { - let authStore; - let organizationsStore; - - beforeEach(() => { - setActivePinia(createPinia()); - authStore = useAuthStore(); - organizationsStore = useOrganizationsStore(); - organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]); - }); - - it('calls fetchOrganizations immediately when isLoggedIn is true at mount', async () => { - authStore.cookieExpire = Date.now() + 3600000; // logged in - organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]); - - shallowMount(UserView, { - global: { - mocks: sharedMocks(), - stubs: sharedStubs, - }, + it('passes /users as basePath to CoreSurfaceTabBar', () => { + const wrapper = shallowMount(UserView, { + global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - - // Flush microtasks so the immediate watcher handler resolves - await new Promise((r) => setTimeout(r, 0)); - expect(organizationsStore.fetchOrganizations).toHaveBeenCalled(); + const tabBar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); + expect(tabBar.props('basePath')).toBe('/users'); }); - it('does not call fetchOrganizations from watcher when isLoggedIn is false at mount', async () => { - authStore.cookieExpire = 0; // logged out - const fetchOrgsSpy = vi.fn().mockResolvedValue([]); - organizationsStore.fetchOrganizations = fetchOrgsSpy; - - shallowMount(UserView, { - global: { - mocks: sharedMocks(), - stubs: sharedStubs, - }, + it('passes a function as can prop to CoreSurfaceTabBar', () => { + const wrapper = shallowMount(UserView, { + global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - - await new Promise((r) => setTimeout(r, 0)); - // The created() hook always calls fetchOrganizations once; the watcher must NOT call it - // a second time when not logged in. Total = 1 (from created), not 2. - expect(fetchOrgsSpy.mock.calls.length).toBeLessThanOrEqual(1); - }); - - it('does NOT have a billingStore.fetchSubscription call (billing decoupled)', async () => { - // Ensure user.view no longer touches any billingStore - authStore.cookieExpire = Date.now() + 3600000; // logged in - - // If user.view still imports billingStore and calls fetchSubscription, this would - // require a mock; its absence means the view is cleanly decoupled. - expect(Object.keys(UserView.components || {})).not.toContain('BillingSubscriptionsComponent'); + const tabBar = wrapper.findComponent({ name: 'CoreSurfaceTabBar' }); + expect(typeof tabBar.props('can')).toBe('function'); }); }); -// ── UserView – C1.2: PageTabs integration ──────────────────────────────────── +// ── UserView – C4 decoupling: no billing / subscriptions ───────────────────── -describe('UserView – renders PageTabs with profile + organizations entries', () => { +describe('UserView – C4 decoupling: billing tab removed from layout', () => { beforeEach(() => { setActivePinia(createPinia()); const organizationsStore = useOrganizationsStore(); organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]); }); - it('renders a [data-test="page-tabs"] element via PageTabs', async () => { - const PageTabsStub = { - template: '
', - inheritAttrs: false, - }; - + it('does not import or render BillingSubscriptionsComponent', () => { const wrapper = shallowMount(UserView, { global: { mocks: sharedMocks(), - stubs: { ...sharedStubs, PageTabs: PageTabsStub }, + stubs: { + ...sharedStubs, + BillingSubscriptionsComponent: { template: '
' }, + }, }, }); - await wrapper.vm.$nextTick(); - expect(wrapper.find('[data-test="page-tabs"]').exists()).toBe(true); - }); - - it('tabsConfig includes profile and organizations entries', () => { - const wrapper = shallowMount(UserView, { - global: { mocks: sharedMocks(), stubs: sharedStubs }, - }); - const config = wrapper.vm.tabsConfig; - expect(Array.isArray(config)).toBe(true); - const values = config.map((t) => t.value); - expect(values).toContain('profile'); - expect(values).toContain('organizations'); + expect(wrapper.html()).not.toContain('data-billing-sentinel'); }); - it('tabsConfig profile entry has correct label', () => { + it('does not expose showSubscriptionsTab computed', () => { const wrapper = shallowMount(UserView, { global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - const profile = wrapper.vm.tabsConfig.find((t) => t.value === 'profile'); - expect(profile?.label).toBe('Profile'); + expect(wrapper.vm.showSubscriptionsTab).toBeUndefined(); }); - it('tabsConfig organizations entry has correct label', () => { + it('does not expose hasOwnerOrAdminRole computed', () => { const wrapper = shallowMount(UserView, { global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - const orgs = wrapper.vm.tabsConfig.find((t) => t.value === 'organizations'); - expect(orgs?.label).toBe('Organizations'); + expect(wrapper.vm.hasOwnerOrAdminRole).toBeUndefined(); }); }); -// ── UserView – delete account danger zone in Profile tab ───────────────────── - -describe('UserView – delete account danger zone', () => { - let authStore; - let organizationsStore; - let wrapper; +// ── UserView – layout is tab-data-free (Gamma) ─────────────────────────────── +describe('UserView – layout is tab-data-free (no inline tab state)', () => { beforeEach(() => { setActivePinia(createPinia()); - authStore = useAuthStore(); - organizationsStore = useOrganizationsStore(); - organizationsStore.fetchOrganizations = vi.fn().mockResolvedValue([]); - authStore.serverConfig = { billing: { enabled: true } }; - organizationsStore.organizations = [{ id: 'o1', role: 'owner' }]; - - wrapper = shallowMount(UserView, { - global: { - mocks: sharedMocks(), - stubs: sharedStubs, - }, - }); }); - it('opens the delete account dialog when confirmDeleteAccount is set to true', async () => { - expect(wrapper.vm.confirmDeleteAccount).toBe(false); - wrapper.vm.confirmDeleteAccount = true; - await wrapper.vm.$nextTick(); - expect(wrapper.vm.confirmDeleteAccount).toBe(true); - }); - - it('confirm button is disabled when deleteConfirmInput is not DELETE', async () => { - // Use a full-stubs setup that captures the :disabled binding from the confirm v-btn - // The confirm button has :disabled="deleteConfirmInput !== 'DELETE'" - const capturedDisabledValues = []; - const vBtnStub = { - template: '', - props: { disabled: { type: Boolean, default: false } }, - created() { - // Capture only the btn that has a disabled binding (the confirm btn) - if (Object.prototype.hasOwnProperty.call(this.$props, 'disabled')) { - capturedDisabledValues.push(this.$props); - } - }, - }; - - const w = shallowMount(UserView, { - global: { - mocks: sharedMocks(), - stubs: { ...sharedStubs, 'v-btn': vBtnStub }, - }, - }); - - w.vm.deleteConfirmInput = 'DELET'; - await w.vm.$nextTick(); - - // The confirm button binding: :disabled="deleteConfirmInput !== 'DELETE'" - expect(w.vm.deleteConfirmInput !== 'DELETE').toBe(true); - // If stubs render a standard ', - props: { disabled: { type: Boolean, default: false } }, - }, - }, - }, + it('does not expose a "tab" data property (tab state lives in router)', () => { + const wrapper = shallowMount(UserView, { + global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - - w.vm.deleteConfirmInput = 'DELETE'; - await w.vm.$nextTick(); - - // :disabled="deleteConfirmInput !== 'DELETE'" → false when input === 'DELETE' - expect(w.vm.deleteConfirmInput === 'DELETE').toBe(true); - // No buttons should have disabled when input is DELETE - const disabledButtons = w.findAll('button[disabled]'); - expect(disabledButtons.length).toBe(0); + // Routing drives active tab — no local tab state needed in the layout. + expect(wrapper.vm.tab).toBeUndefined(); }); - it('deleteAccount method calls axios.delete and redirects to /signin on success', async () => { - const routerPush = vi.fn(); - authStore.signout = vi.fn().mockResolvedValue(); - axios.delete.mockResolvedValue({}); - - const w = shallowMount(UserView, { - global: { - mocks: sharedMocks({ push: routerPush }), - stubs: sharedStubs, - }, + it('does not expose tabsConfig (tabs now come from config.users.tabs)', () => { + const wrapper = shallowMount(UserView, { + global: { mocks: sharedMocks(), stubs: sharedStubs }, }); - - await w.vm.deleteAccount(); - - expect(axios.delete).toHaveBeenCalledWith(expect.stringContaining('/users')); - expect(authStore.signout).toHaveBeenCalled(); - expect(routerPush).toHaveBeenCalledWith('/signin'); + expect(wrapper.vm.tabsConfig).toBeUndefined(); }); - it('deleteAccount method closes dialog on error (swallows exception)', async () => { - axios.delete.mockRejectedValue(new Error('Server error')); - - const w = shallowMount(UserView, { + it('does not render PageTabs (replaced by CoreSurfaceTabBar)', () => { + const PageTabsSentinel = { template: '
' }; + const wrapper = shallowMount(UserView, { global: { mocks: sharedMocks(), - stubs: sharedStubs, + stubs: { ...sharedStubs, PageTabs: PageTabsSentinel }, }, }); - - w.vm.confirmDeleteAccount = true; - w.vm.deleteConfirmInput = 'DELETE'; - await w.vm.deleteAccount(); - - expect(w.vm.confirmDeleteAccount).toBe(false); - expect(w.vm.deleteConfirmInput).toBe(''); - }); - - it('deleteAccount strings are inlined in user.view.vue', () => { - const html = wrapper.html(); - expect(html).toContain('Delete account'); - expect(html).toContain('Permanently delete your account, data, and organization ownership. This cannot be undone.'); - expect(html).toContain('Type DELETE to confirm'); + expect(wrapper.find('[data-test="page-tabs-sentinel"]').exists()).toBe(false); }); }); diff --git a/src/modules/users/tests/users.config.unit.tests.js b/src/modules/users/tests/users.config.unit.tests.js new file mode 100644 index 000000000..9283a063c --- /dev/null +++ b/src/modules/users/tests/users.config.unit.tests.js @@ -0,0 +1,12 @@ +import { describe, test, expect } from 'vitest'; + +describe('users config – users.tabs', () => { + test('users.tabs has Profile + Organizations descriptors in order', async () => { + // Import via the generated config index so any future merges are covered. + const config = (await import('../../../config/index.js')).default; + expect(config.users.tabs).toEqual([ + { value: 'profile', label: 'Profile', icon: 'fa-solid fa-id-card', route: 'profile' }, + { value: 'organizations', label: 'Organizations', icon: 'fa-solid fa-building', route: 'organizations' }, + ]); + }); +}); diff --git a/src/modules/users/tests/users.router.unit.tests.js b/src/modules/users/tests/users.router.unit.tests.js index 72971d976..6521aa8a5 100644 --- a/src/modules/users/tests/users.router.unit.tests.js +++ b/src/modules/users/tests/users.router.unit.tests.js @@ -120,3 +120,41 @@ describe('users.router – C4 /billing redirect regression', () => { expect(billingAccountRoute).toBeUndefined(); }); }); + +describe('users.router – Gamma: Account child routes (route-driven tabs)', () => { + /** @returns {import('vue-router').RouteRecordRaw|undefined} */ + const getAccountRoute = () => usersRoutes.find((r) => r.path === '/users'); + + it('Account route has children array', () => { + const route = getAccountRoute(); + expect(Array.isArray(route.children)).toBe(true); + expect(route.children.length).toBeGreaterThanOrEqual(3); + }); + + it('bare path "" child redirects to Account Profile', () => { + const route = getAccountRoute(); + const bareChild = route.children.find((c) => c.path === ''); + expect(bareChild).toBeDefined(); + expect(bareChild.redirect).toEqual({ name: 'Account Profile' }); + }); + + it('Account Profile child has path "profile" and requires auth (no CASL — matches pre-Gamma)', () => { + const route = getAccountRoute(); + const profile = route.children.find((c) => c.name === 'Account Profile'); + expect(profile).toBeDefined(); + expect(profile.path).toBe('profile'); + expect(profile.meta.requiresAuth).toBe(true); + expect(profile.meta.action).toBeUndefined(); + expect(profile.meta.subject).toBeUndefined(); + }); + + it('Account Organizations child has path "organizations" and requires auth (no CASL — matches pre-Gamma)', () => { + const route = getAccountRoute(); + const orgs = route.children.find((c) => c.name === 'Account Organizations'); + expect(orgs).toBeDefined(); + expect(orgs.path).toBe('organizations'); + expect(orgs.meta.requiresAuth).toBe(true); + expect(orgs.meta.action).toBeUndefined(); + expect(orgs.meta.subject).toBeUndefined(); + }); +}); diff --git a/src/modules/users/views/user.organizations.view.vue b/src/modules/users/views/user.organizations.view.vue new file mode 100644 index 000000000..5ac7094f3 --- /dev/null +++ b/src/modules/users/views/user.organizations.view.vue @@ -0,0 +1,171 @@ + + + diff --git a/src/modules/users/views/user.profile.view.vue b/src/modules/users/views/user.profile.view.vue new file mode 100644 index 000000000..abebc7056 --- /dev/null +++ b/src/modules/users/views/user.profile.view.vue @@ -0,0 +1,137 @@ + + + diff --git a/src/modules/users/views/user.view.vue b/src/modules/users/views/user.view.vue index ed31fdb58..a437a219c 100644 --- a/src/modules/users/views/user.view.vue +++ b/src/modules/users/views/user.view.vue @@ -1,214 +1,87 @@