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