/**
* Tests for the Layout component.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { Layout } from '../../components/Layout';
import { getAuthToken, setAuthToken } from '../../api/client';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
describe('Layout', () => {
beforeEach(() => {
vi.mocked(localStorage.getItem).mockReset();
vi.mocked(localStorage.setItem).mockReset();
vi.mocked(localStorage.removeItem).mockReset();
vi.mocked(localStorage.clear).mockReset();
localStorage.clear();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json([
{ id: 1, name: 'X1 Carbon', model: 'X1C', enabled: true },
]);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({
connected: true,
state: 'IDLE',
});
}),
http.get('/api/v1/version', () => {
return HttpResponse.json({ version: '0.1.6', build: 'test' });
}),
http.get('/api/v1/settings/', () => {
return HttpResponse.json({
check_updates: false,
check_printer_firmware: false,
auto_archive: true,
});
}),
// What the sidebar actually gates on. Layout used to read these from
// /settings/, which a non-admin cannot fetch (#3023).
http.get('/api/v1/settings/ui-flags', () => {
return HttpResponse.json({
check_updates: false,
billing_enabled: false,
user_notifications_enabled: true,
currency: 'EUR',
});
}),
http.get('/api/v1/external-links/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/smart-plugs/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/support/debug-logging', () => {
return HttpResponse.json({ enabled: false });
}),
http.get('/api/v1/queue/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/pending-uploads/count', () => {
return HttpResponse.json({ count: 0 });
}),
http.get('/api/v1/updates/check', () => {
return HttpResponse.json({ update_available: false });
}),
http.get('/api/v1/auth/status', () => {
return HttpResponse.json({ auth_enabled: false, requires_setup: false });
}),
http.get('/api/v1/printers/developer-mode-warnings', () => {
return HttpResponse.json([]);
})
);
});
describe('rendering', () => {
it('renders the sidebar', async () => {
render();
// Layout renders as a flex container with sidebar
await waitFor(() => {
const sidebar = document.querySelector('aside');
expect(sidebar).toBeInTheDocument();
});
});
it('renders navigation links', async () => {
render();
await waitFor(() => {
// Navigation links should be present
const links = document.querySelectorAll('a');
expect(links.length).toBeGreaterThan(0);
});
});
});
describe('navigation', () => {
it('has navigation items', async () => {
render();
await waitFor(() => {
// Should have multiple navigation links
const navLinks = document.querySelectorAll('a[href]');
expect(navLinks.length).toBeGreaterThan(0);
});
});
it('includes settings link', async () => {
render();
await waitFor(() => {
// Settings link should exist (route /settings)
const settingsLink = document.querySelector('a[href="/settings"]');
expect(settingsLink).toBeInTheDocument();
});
});
it('hides system nav items stored in sidebar layout preferences', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
return null;
});
render();
await waitFor(() => {
const sidebar = document.querySelector('aside');
expect(sidebar).toBeInTheDocument();
expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
});
expect(document.querySelector('aside a[href="/"]')).toBeNull();
});
it('applies admin default sidebar hidden state with the default order', async () => {
const storage: Record = {};
vi.mocked(localStorage.getItem).mockImplementation((key) => storage[key] ?? null);
vi.mocked(localStorage.setItem).mockImplementation((key, value) => {
storage[key] = value;
});
server.use(
http.get('/api/v1/settings/default-sidebar-order', () =>
HttpResponse.json({
default_sidebar_order: JSON.stringify({
order: ['inventory', 'printers', 'settings'],
hiddenSystemItemIds: ['printers'],
}),
}),
),
);
render();
await waitFor(() => {
const sidebar = document.querySelector('aside');
expect(sidebar).toBeInTheDocument();
expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
});
await waitFor(() => {
expect(document.querySelector('aside a[href="/"]')).toBeNull();
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_ORDER_KEY, JSON.stringify(['inventory', 'printers', 'settings']));
expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
});
});
});
describe('finance nav item', () => {
it('stays out of the sidebar while billing is off', async () => {
// billing_enabled defaults to false and the Finance page has nothing to
// show without it, so the entry must not be there at all.
render();
await waitFor(() => {
expect(document.querySelector('aside a[href="/stats"]')).toBeInTheDocument();
});
expect(document.querySelector('aside a[href="/finance"]')).toBeNull();
});
it('appears between Statistics and Settings once billing is on', async () => {
server.use(
http.get('/api/v1/settings/ui-flags', () =>
HttpResponse.json({
check_updates: false,
billing_enabled: true,
user_notifications_enabled: true,
currency: 'EUR',
}),
),
);
render();
await waitFor(() => {
expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
});
const sidebar = document.querySelector('aside');
const hrefs = Array.from(sidebar?.querySelectorAll('a[href]') ?? []).map((a) => a.getAttribute('href'));
expect(hrefs.indexOf('/finance')).toBeGreaterThan(hrefs.indexOf('/stats'));
expect(hrefs.indexOf('/finance')).toBeLessThan(hrefs.indexOf('/settings'));
});
});
describe('Sidebar gates survive a user who cannot read /settings (#3023)', () => {
// Every gate below used to be fed by GET /settings, which requires
// settings:read. A non-admin gets 403 there, so the value arrived
// undefined and each gate silently took its fallback -- in opposite
// directions, which is why only one of the two was ever reported.
let priorToken: string | null = null;
const asNonAdmin = (permissions: string[]) => {
server.use(
http.get('/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('/api/v1/auth/me', () =>
HttpResponse.json({
id: 2,
username: 'operator',
role: 'user',
is_active: true,
is_admin: false,
groups: [{ id: 2, name: 'Operators' }],
permissions,
created_at: '2026-01-01T00:00:00Z',
}),
),
// The 403 that started it. Layout must not need this call at all.
http.get('/api/v1/settings/', () =>
HttpResponse.json({ detail: 'Not enough permissions' }, { status: 403 }),
),
);
// localStorage is a no-op mock in setup.ts, so writing the key there
// authenticates nobody. Set the client's token directly.
priorToken = getAuthToken();
setAuthToken('test-token', 'session');
};
afterEach(() => {
setAuthToken(priorToken, 'session');
priorToken = null;
});
it('shows Finance to a user with cost_centers:read_own and no settings:read', async () => {
asNonAdmin(['cost_centers:read_own']);
server.use(
http.get('/api/v1/settings/ui-flags', () =>
HttpResponse.json({ billing_enabled: true, user_notifications_enabled: true }),
),
);
render();
await waitFor(() => {
expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
});
});
it('still hides Finance from that user when billing is off', async () => {
// Waits on Notifications appearing rather than on the sidebar existing.
// Asserting absence the moment