Skip to content
22 changes: 21 additions & 1 deletion frontend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ jest.mock('../navigation', () => ({
applyTabFromPath: jest.fn().mockReturnValue('dashboard'),
initRouter: jest.fn(),
switchSettingsSubTab: jest.fn(),
getSettingsSubTabFromPath: jest.fn().mockReturnValue('general'),
// Deliberately not '/' + tab: init() must write whatever canonicalTabPath
// returns into the initial replaceState, because that is what preserves a
// deep-linked sub-tab segment for switchTab to read back.
canonicalTabPath: jest.fn((tab: string) => `/${tab}/sub-segment`),
}));

jest.mock('../recommendations', () => ({
Expand Down Expand Up @@ -110,6 +113,23 @@ describe('App Module', () => {
expect(auth.updateUserUI).toHaveBeenCalled();
});

test('seeds the URL from canonicalTabPath before routing', async () => {
(api.isAuthenticated as jest.Mock).mockReturnValue(true);
(api.getCurrentUser as jest.Mock).mockResolvedValue({ id: 'user-1', email: 'test@example.com' });
(navigation.applyTabFromPath as jest.Mock).mockReturnValue('inventory');
const replaceState = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});

await init();

expect(navigation.canonicalTabPath).toHaveBeenCalledWith('inventory');
expect(replaceState).toHaveBeenCalledWith(
{ tab: 'inventory', id: 0 },
'',
expect.stringContaining('/inventory/sub-segment'),
);
replaceState.mockRestore();
});

test('shows login modal on 401 error', async () => {
(api.isAuthenticated as jest.Mock).mockReturnValue(true);
(api.getCurrentUser as jest.Mock).mockRejectedValue({ status: 401 });
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/__tests__/four-eyes-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,41 @@ describe('4-eyes approval mode (issue #1005)', () => {
expect(document.getElementById('four-eyes-banner')?.classList.contains('hidden')).toBe(true);
});

// An unreadable config must fail closed. Failing open would offer the
// creator an Approve action the backend rejects, which is a UI claim the
// system will not honour.
test('dual control is ON when the config fetch fails', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(REG_USER);
(api.getConfig as jest.Mock).mockRejectedValue(new Error('config unavailable'));
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: 'exec-own', created_by_user_id: REG_USER.id })],
});

await loadHistory();

const list = document.getElementById('history-list')!;
expect(list.querySelectorAll('.history-approve-btn')).toHaveLength(0);
expect(document.getElementById('four-eyes-banner')?.classList.contains('hidden')).toBe(false);
});

// A config that reads successfully but omits the flag is a known "off",
// not an outage, so it must not be forced closed.
test('dual control is OFF when the config reads successfully without the flag', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(REG_USER);
(api.getConfig as jest.Mock).mockResolvedValue({ global: {} });
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: 'exec-own', created_by_user_id: REG_USER.id })],
});

await loadHistory();

const list = document.getElementById('history-list')!;
expect(list.querySelectorAll('.history-approve-btn')).toHaveLength(1);
expect(document.getElementById('four-eyes-banner')?.classList.contains('hidden')).toBe(true);
});

test('badge shown when button hidden by mode (admin approve-any self-approval)', async () => {
// Admin holds approve-any, which would normally show Approve on every
// pending row regardless of creator. Four-eyes still blocks self-approval
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ jest.mock('../navigation', () => ({
switchTab: jest.fn()
}));

// Default: the session may view purchases. viewPlanHistory consults this
// before rendering into the Purchases tab, because switchTab renders a
// no-access placeholder there for sessions without view:purchases.
jest.mock('../permissions', () => ({
canAccess: jest.fn().mockReturnValue(true),
}));

jest.mock('../utils', () => ({
// Mirrors the real formatCurrency behaviour: null/undefined/NaN -> '--', numbers -> '$<val>'
formatCurrency: jest.fn((val) => (val === null || val === undefined || isNaN(val)) ? '--' : `$${val}`),
Expand Down Expand Up @@ -57,6 +64,7 @@ jest.mock('../state', () => ({

import * as api from '../api';
import { switchTab } from '../navigation';
import { canAccess } from '../permissions';

describe('History Module', () => {
beforeEach(() => {
Expand Down Expand Up @@ -122,15 +130,27 @@ describe('History Module', () => {
});

describe('viewPlanHistory', () => {
test('switches to history tab', async () => {
// Issue #1775: 'history' was the pre-#340 tab name, so this silently fell
// back to Home and the Plans page's "View history" button rendered the
// Home dashboard.
test('switches to the Purchases tab without its default load', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: []
});

await viewPlanHistory('plan-123');

expect(switchTab).toHaveBeenCalledWith('history');
expect(switchTab).toHaveBeenCalledWith('purchases', { skipDefaultLoad: true });
});

test('does not fetch when the session cannot view purchases', async () => {
(canAccess as jest.Mock).mockReturnValueOnce(false);

await viewPlanHistory('plan-123');

expect(switchTab).toHaveBeenCalledWith('purchases', { skipDefaultLoad: true });
expect(api.getHistory).not.toHaveBeenCalled();
});

test('calls getHistory with planId filter', async () => {
Expand Down
133 changes: 131 additions & 2 deletions frontend/src/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Navigation module tests
*/
import { switchTab, switchSettingsSubTab, switchInventorySubTab, getSettingsSubTabFromPath, getInventorySubTabFromPath } from '../navigation';
import { switchTab, switchSettingsSubTab, switchInventorySubTab, getSettingsSubTabFromPath, getInventorySubTabFromPath, applyTabFromPath, canonicalTabPath } from '../navigation';

// Mock the dependent modules
jest.mock('../dashboard', () => ({
Expand Down Expand Up @@ -61,6 +61,7 @@ import { loadGlobalSettings } from '../settings';
import { loadAutomationSettings } from '../riexchange';
import { canAccess } from '../permissions';
import { loadInventory } from '../inventory';
import { isAdmin } from '../auth';

describe('Navigation Module', () => {
beforeEach(() => {
Expand Down Expand Up @@ -94,8 +95,13 @@ describe('Navigation Module', () => {
</div>
`;

// Clear all mocks
// Clear all mocks. clearAllMocks() only drops recorded calls, not
// implementations, so restore the permissive defaults explicitly --
// otherwise a test that flips isAdmin/canAccess to false leaks that into
// every test declared after it.
jest.clearAllMocks();
(isAdmin as jest.Mock).mockReturnValue(true);
(canAccess as jest.Mock).mockReturnValue(true);
});

describe('switchTab', () => {
Expand Down Expand Up @@ -437,6 +443,129 @@ describe('Navigation Module', () => {
});
});

// Issue #1775: resolving the URL on a direct load / refresh. Placed BEFORE
// the *FromPath describes for the same reason as switchInventorySubTab --
// those replace window.location with a plain object.
describe('applyTabFromPath', () => {
test.each([
['/home', 'home'],
['/opportunities', 'opportunities'],
['/plans', 'plans'],
['/purchases', 'purchases'],
['/inventory', 'inventory'],
['/admin', 'admin'],
['/', 'home'],
['/plans/', 'plans'],
['/PLANS', 'plans'],
['/plans/extra/segments', 'plans'],
['/not-a-route', 'home'],
])('%s resolves to the %s tab', (path, expected) => {
window.history.replaceState(null, '', path);
expect(applyTabFromPath()).toBe(expected);
});

test.each([
['/dashboard', 'home'],
['/recommendations', 'opportunities'],
['/history', 'purchases'],
['/settings', 'admin'],
['/ri-exchange', 'inventory'],
])('legacy %s redirects to the %s tab and rewrites the URL', (path, expected) => {
window.history.replaceState(null, '', path);
expect(applyTabFromPath()).toBe(expected);
expect(window.location.pathname).toBe('/' + expected);
});

// A path whose first segment names an Object.prototype member used to pass
// the `segment in TABS` membership test, so switchTab then read an
// undefined TabMeta (or the Object constructor) and threw out of init(),
// stranding the app on its unrouted default markup.
test.each(['/constructor', '/toString', '/valueOf', '/hasOwnProperty', '/__proto__'])(
'%s is not a known route and falls back to home',
(path) => {
window.history.replaceState(null, '', path);
expect(applyTabFromPath()).toBe('home');
},
);

test('an Object.prototype sub-tab name is not a known admin sub-tab', () => {
window.history.replaceState(null, '', '/admin/constructor');
expect(getSettingsSubTabFromPath()).toBe('general');
switchSettingsSubTab(getSettingsSubTabFromPath(), { push: false });
expect(document.title).toBe('CUDly — Admin · General');
});
});

// canonicalTabPath is what app.ts writes into the initial replaceState, so
// it must keep the sub-tab segment switchTab reads back out of the URL.
describe('canonicalTabPath', () => {
test.each(['home', 'opportunities', 'plans', 'purchases'])(
'%s has no sub-tab segment',
(tab) => {
window.history.replaceState(null, '', '/' + tab);
expect(canonicalTabPath(tab)).toBe('/' + tab);
},
);

test.each(['active-commitments', 'coverage', 'ri-exchange'])(
'a deep-linked /inventory/%s keeps its sub-tab segment',
(sub) => {
window.history.replaceState(null, '', '/inventory/' + sub);
expect(canonicalTabPath('inventory')).toBe('/inventory/' + sub);
},
);

test('a bare /inventory canonicalises to the default sub-tab', () => {
window.history.replaceState(null, '', '/inventory');
expect(canonicalTabPath('inventory')).toBe('/inventory/active-commitments');
});

test.each(['general', 'purchasing', 'accounts', 'users'])(
'a deep-linked /admin/%s keeps its sub-tab segment',
(sub) => {
window.history.replaceState(null, '', '/admin/' + sub);
// currentSettingsSubTab is module state; drive it through the real
// entry point so the assertion reflects an actual navigation.
switchSettingsSubTab(sub, { push: false });
expect(canonicalTabPath('admin')).toBe('/admin/' + sub);
},
);

// app.ts calls this at init, before anything has set currentSettingsSubTab,
// so the URL is the only source for the segment. A fresh module instance is
// the only way to observe that state from inside this file.
test('falls back to the URL when no admin sub-tab has been visited yet', () => {
window.history.replaceState(null, '', '/admin/users');
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nav = require('../navigation') as typeof import('../navigation');
expect(nav.canonicalTabPath('admin')).toBe('/admin/users');
});
});
});

describe('switchTab skipDefaultLoad', () => {
test('purchases: reveals the tab without firing its default loads', () => {
switchTab('purchases', { skipDefaultLoad: true, push: false });
expect(document.getElementById('purchases-tab')?.classList.contains('active')).toBe(true);
expect(initHistoryDateRange).not.toHaveBeenCalled();
expect(loadHistory).not.toHaveBeenCalled();
});

test('purchases: the view:purchases gate still applies', () => {
(canAccess as jest.Mock).mockReturnValue(false);
switchTab('purchases', { skipDefaultLoad: true, push: false });
expect(document.getElementById('purchases-tab')?.textContent).toContain('do not have access');
expect(loadHistory).not.toHaveBeenCalled();
});

test('admin: reveals the tab without loading the default sub-tab', () => {
switchTab('admin', { skipDefaultLoad: true, push: false });
expect(document.getElementById('admin-tab')?.classList.contains('active')).toBe(true);
expect(loadGlobalSettings).not.toHaveBeenCalled();
});
});

describe('getSettingsSubTabFromPath', () => {
// Canonical /admin/* paths (issue #340 IA rename)
test('returns general for root admin path', () => {
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,11 +673,14 @@ describe('⚙︎ Exchange settings deep-link', () => {
jest.resetAllMocks();
});

it('switches to Settings → Purchasing when clicked', () => {
// Issue #1775: 'settings' was the pre-#340 tab name, so switchTab fell back
// to Home while switchSettingsSubTab still set the Admin title and pushed
// /admin/purchasing -- URL and title said Admin over the Home dashboard.
it('switches to Admin → Purchasing when clicked', () => {
setupRIExchangeHandlers();
const btn = document.getElementById('ri-exchange-settings-btn')!;
btn.click();
expect(navigation.switchTab).toHaveBeenCalledWith('settings');
expect(navigation.switchTab).toHaveBeenCalledWith('admin', { push: false, skipDefaultLoad: true });
expect(navigation.switchSettingsSubTab).toHaveBeenCalledWith('purchasing');
});
});
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as state from './state';
import { showLoginModal, showAdminSetupModal, showResetPasswordModal, updateUserUI } from './auth';
import { loadDashboard, setupDashboardHandlers } from './dashboard';
import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, type FanOutBucket } from './recommendations';
import { switchTab, applyTabFromPath, initRouter, switchSettingsSubTab, getSettingsSubTabFromPath } from './navigation';
import { switchTab, applyTabFromPath, initRouter, switchSettingsSubTab, canonicalTabPath } from './navigation';
import { savePlan, setupPlanHandlers, closePlanModal, openNewPlanModal, closePurchaseModal } from './plans';
import { saveGlobalSettings, setupSettingsHandlers, resetSettings } from './settings';
import { setupUserHandlers } from './users';
Expand Down Expand Up @@ -91,14 +91,12 @@ export async function init(): Promise<void> {
// tab routing still runs underneath so the app is fully functional.
handleArcheraDeeplink();
const target = applyTabFromPath();
let url = '/' + target;
if (target === 'admin') {
url = '/admin/' + getSettingsSubTabFromPath();
}
// switchTab below re-reads the sub-tab from the URL, so this rewrite must
// keep the segment of a deep-linked /inventory/<subtab> or /admin/<subtab>.
window.history.replaceState(
{ tab: target, id: 0 },
'',
url + window.location.search + window.location.hash,
canonicalTabPath(target) + window.location.search + window.location.hash,
);
switchTab(target, { push: false });
setupEventListeners();
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CUDly API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.32.6/swagger-ui.css" integrity="sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="./docs.css">
<!-- Absolute so the page styles correctly at both /docs and /docs/. -->
<link rel="stylesheet" type="text/css" href="/docs/docs.css">
</head>
<body>
<div id="swagger-ui"></div>
Expand Down
Loading
Loading