Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment on lines +304 to +305
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Avoid networkidle here; prefer deterministic readiness checks.

networkidle can introduce E2E flakiness when background requests are present. Since you already assert the target list item visibility, remove networkidle and rely on explicit element/API completion conditions instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/organizations/tests/organizations.domainJoin.e2e.tests.js` around
lines 304 - 305, Remove the brittle page.waitForLoadState('networkidle') call
after page.goto('/users/organizations') and instead wait deterministically for
the UI to be ready (for example use page.waitForSelector or waitForResponse)
that matches the target list item you already assert; update the test around
page.goto and the subsequent visibility assertion so it waits explicitly for the
organization list item selector or the specific API response used to populate
that list before performing the visibility assertion.


// Wait for the domain org list item to appear
const domainOrgItem = page.locator('.v-list-item', { hasText: `DomainOrg${timestamp}` });
Expand Down
6 changes: 6 additions & 0 deletions src/modules/users/config/users.development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
};
29 changes: 29 additions & 0 deletions src/modules/users/router/users.router.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,36 @@ export default [
order: 10, // sidenav sort order within bottom section (lower = first)
position: 'bottom',
requiresAuth: true,
action: 'read',
subject: 'User',
},
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,
action: 'read',
subject: 'User',
},
},
{
path: 'organizations',
name: 'Account Organizations',
component: () => import('../views/user.organizations.view.vue'),
Comment on lines +58 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add JSDoc for the new lazy-load route component factories.

Both new function literals (() => import(...)) are added without JSDoc, which breaks the repo’s JS/Vue function documentation requirement.

As per coding guidelines, “Every new or modified function must have a JSDoc header with one-line description, @param for each argument, and @returns for any non-void return value (always include @returns for async functions)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/users/router/users.router.js` around lines 60 - 70, The anonymous
lazy-load component factories (the arrow functions assigned to the component
property in users.router.js for 'user.profile.view' and
'user.organizations.view') lack JSDoc; add a JSDoc block immediately above each
component property describing the function in one line and including a `@returns`
tag indicating it returns a Promise resolving to the Vue component (e.g.,
"`@returns` {Promise<*>} Promise resolving to the component"). There are no params
so omit `@param` blocks; ensure the JSDoc is placed directly above the component:
() => import('...') entries so the linter/documentation picks it up.

meta: {
display: false,
action: 'read',
subject: 'User',
},
},
],
},
{
path: '/users/:id',
Expand Down
130 changes: 130 additions & 0 deletions src/modules/users/tests/user.organizations.view.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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: '<div />' },
'v-container': { template: '<div><slot /></div>' },
'v-list': { template: '<div><slot /></div>' },
'v-list-item': { template: '<div><slot /></div>' },
'v-list-item-title': { template: '<div><slot /></div>' },
'v-list-item-subtitle': { template: '<div><slot /></div>' },
'v-divider': { template: '<div />' },
'v-chip': { template: '<div><slot /></div>' },
'v-btn': { template: '<button v-bind="$attrs" :to="$attrs.to"><slot /></button>', inheritAttrs: false },
'v-icon': { template: '<div />' },
'v-dialog': { template: '<div><slot /></div>' },
'v-card': { template: '<div><slot /></div>' },
'v-card-title': { template: '<div><slot /></div>' },
'v-card-text': { template: '<div><slot /></div>' },
'v-card-actions': { template: '<div><slot /></div>' },
'v-spacer': { template: '<div />' },
};

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 } },
},
});
Comment on lines +37 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Document sharedMocks with JSDoc.

sharedMocks is a standalone function and needs a JSDoc header (@param, @returns) under the current repo rule set.

As per coding guidelines, every function must have JSDoc header with @param and @returns annotations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/users/tests/user.organizations.view.unit.tests.js` around lines
37 - 44, Add a JSDoc header for the sharedMocks function describing its input
and output: document the optional parameter $router (type: object with push
function, default vi.fn()) using `@param` and describe the returned mock object
shape (properties $router, $route, config with api and vuetify) using `@returns`;
place the comment directly above the sharedMocks definition so tools and linters
recognize it.


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');
});
});
125 changes: 125 additions & 0 deletions src/modules/users/tests/user.profile.view.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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: '<div data-test="user-profile-component" />', name: 'UserProfileComponent' },
'v-container': { template: '<div><slot /></div>' },
'v-card': { template: '<div><slot /></div>' },
'v-card-title': { template: '<div><slot /></div>' },
'v-card-text': { template: '<div><slot /></div>' },
'v-card-actions': { template: '<div><slot /></div>' },
'v-btn': { template: '<button v-bind="$attrs"><slot /></button>' },
'v-dialog': { template: '<div><slot /></div>' },
'v-text-field': { template: '<div />' },
'v-spacer': { template: '<div />' },
};

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