mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
test(client): expand frontend test suite to 69.1% coverage
Add and extend tests across 32 files (+10 595 lines) covering Admin panels (AuditLog, Backup, DevNotifications, GitHub), Collab (Chat, Notes, Panel, Polls), Planner (DayDetailPanel, DayPlanSidebar), Settings (DisplaySettings, Integrations, MapSettings), Files (FileManager, FilesPage), Map, Layout (DemoBanner, InAppNotificationBell), shared pickers (CustomDateTimePicker, CustomTimePicker), Vacay holidays, pages (Dashboard, Login), unit stores (authStore, inAppNotificationStore), API (authUrl, client integration), and i18n. Also updates sonar-project.properties and MSW trip handlers to support the new cases.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-012
|
||||
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-027
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import DisplaySettingsTab from './DisplaySettingsTab';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
@@ -88,4 +89,125 @@ describe('DisplaySettingsTab', () => {
|
||||
await user.click(screen.getByText('Light'));
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'light');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-013: clicking Auto mode button calls updateSetting with auto', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('Auto'));
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const darkBtn = screen.getByText('Dark').closest('button')!;
|
||||
const lightBtn = screen.getByText('Light').closest('button')!;
|
||||
const autoBtn = screen.getByText('Auto').closest('button')!;
|
||||
expect(darkBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(lightBtn.style.border).toContain('var(--border-primary)');
|
||||
expect(autoBtn.style.border).toContain('var(--border-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-015: clicking a language button calls updateSetting with that language code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('Deutsch'));
|
||||
expect(updateSetting).toHaveBeenCalledWith('language', 'de');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const englishBtn = screen.getByText('English').closest('button')!;
|
||||
expect(englishBtn.style.border).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByText(/temperature/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-018: celsius button is active when temperature_unit is celsius', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const celsiusBtn = screen.getByText('°C Celsius').closest('button')!;
|
||||
expect(celsiusBtn.style.border).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-019: clicking fahrenheit button calls updateSetting with fahrenheit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('°F Fahrenheit'));
|
||||
expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('24h (14:30)'));
|
||||
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-021: shows Route Calculation section', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByText(/route calculation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-022: route calculation On button is active when route_calculation is true', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const onButtons = screen.getAllByText(/^On$/i);
|
||||
const routeCalcOnBtn = onButtons[0].closest('button')!;
|
||||
expect(routeCalcOnBtn.style.border).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-023: clicking route calculation Off calls updateSetting with false', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
const offButtons = screen.getAllByText(/^Off$/i);
|
||||
await user.click(offButtons[0]);
|
||||
expect(updateSetting).toHaveBeenCalledWith('route_calculation', false);
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-025: blur booking codes On button is active when blur_booking_codes is true', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: true }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const onButtons = screen.getAllByText(/^On$/i);
|
||||
const blurOnBtn = onButtons[1].closest('button')!;
|
||||
expect(blurOnBtn.style.border).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-026: updateSetting failure shows toast error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockRejectedValue(new Error('Server error'));
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
await user.click(screen.getByText('Dark'));
|
||||
await screen.findByText('Server error');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-027: temperature unit local state updates optimistically before API resolves', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockReturnValue(new Promise(() => {}));
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('°F Fahrenheit'));
|
||||
const fahrenheitBtn = screen.getByText('°F Fahrenheit').closest('button')!;
|
||||
expect(fahrenheitBtn.style.border).toContain('var(--text-primary)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-018
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import IntegrationsTab from './IntegrationsTab';
|
||||
|
||||
function enableMcp() {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
loadAddons: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: clipboardWriteText },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clipboardWriteText.mockClear();
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useAddonStore, {
|
||||
addons: [],
|
||||
loaded: true,
|
||||
loadAddons: vi.fn(),
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('IntegrationsTab', () => {
|
||||
it('FE-COMP-INTEGRATIONS-001: renders without crashing (MCP disabled)', () => {
|
||||
render(<IntegrationsTab />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-002: MCP section is hidden when mcp addon is disabled', () => {
|
||||
render(<IntegrationsTab />);
|
||||
expect(screen.queryByText('MCP Configuration')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-003: MCP section is visible when mcp addon is enabled', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-004: MCP endpoint URL is displayed', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
const codeEl = document.querySelector('code');
|
||||
expect(codeEl).not.toBeNull();
|
||||
expect(codeEl!.textContent).toContain('/mcp');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
const preEl = document.querySelector('pre');
|
||||
expect(preEl).not.toBeNull();
|
||||
expect(preEl!.textContent).toContain('mcpServers');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('No tokens yet. Create one to connect MCP clients.');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-007: token list renders when tokens exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () =>
|
||||
HttpResponse.json({
|
||||
tokens: [
|
||||
{ id: 1, name: 'My Token', token_prefix: 'tk_aaa', created_at: '2025-01-01T00:00:00.000Z', last_used_at: null },
|
||||
{ id: 2, name: 'Other Token', token_prefix: 'tk_bbb', created_at: '2025-01-01T00:00:00.000Z', last_used_at: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('My Token');
|
||||
await screen.findByText('Other Token');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-008: clicking "Create New Token" button opens the modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
const createBtn = screen.getByRole('button', { name: /Create New Token/i });
|
||||
await user.click(createBtn);
|
||||
await screen.findByText('Create API Token');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-009: Create button in modal is disabled when name is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
|
||||
expect(modalCreateBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-010: Create button in modal becomes enabled when name is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
await user.type(input, 'My API token');
|
||||
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
|
||||
expect(modalCreateBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-011: creating a token calls the API and shows the raw token', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/mcp-tokens', () =>
|
||||
HttpResponse.json({
|
||||
token: {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
token_prefix: 'tk_abc',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
raw_token: 'tk_abc...full_secret_token',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
await user.type(input, 'test');
|
||||
await user.click(screen.getByRole('button', { name: /^Create Token$/i }));
|
||||
// Raw token should be displayed
|
||||
await screen.findByText(/tk_abc\.\.\.full_secret_token/);
|
||||
// Warning about one-time display
|
||||
expect(screen.getByText(/only be shown once/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-012: "Done" button closes the token-created modal', async () => {
|
||||
server.use(
|
||||
http.post('/api/auth/mcp-tokens', () =>
|
||||
HttpResponse.json({
|
||||
token: {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
token_prefix: 'tk_abc',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
raw_token: 'tk_abc...full_secret_token',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
|
||||
await user.click(screen.getByRole('button', { name: /^Create Token$/i }));
|
||||
await screen.findByText('Token Created');
|
||||
await user.click(screen.getByRole('button', { name: /^Done$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Token Created')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-013: clicking the delete button next to a token opens the confirm modal', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () =>
|
||||
HttpResponse.json({
|
||||
tokens: [
|
||||
{ id: 1, name: 'Delete Me', token_prefix: 'tk_del', created_at: '2025-01-01T00:00:00.000Z', last_used_at: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
|
||||
expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-014: confirming deletion calls DELETE API and removes token from list', async () => {
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () =>
|
||||
HttpResponse.json({
|
||||
tokens: [
|
||||
{ id: 1, name: 'Delete Me', token_prefix: 'tk_del', created_at: '2025-01-01T00:00:00.000Z', last_used_at: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
http.delete('/api/auth/mcp-tokens/1', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
|
||||
const deleteButtons = await screen.findAllByRole('button', { name: /^Delete Token$/i });
|
||||
// Click the one in the modal (last one, or the standalone one without title attribute)
|
||||
const confirmBtn = deleteButtons.find(btn => !btn.title);
|
||||
await user.click(confirmBtn ?? deleteButtons[deleteButtons.length - 1]);
|
||||
expect(deleteCalled).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Me')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-015: copying endpoint URL calls clipboard.writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
// Spy after userEvent.setup() may have replaced navigator.clipboard
|
||||
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
const copyBtns = screen.getAllByTitle('Copy');
|
||||
await user.click(copyBtns[0]);
|
||||
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('/mcp'));
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-016: copy button shows checkmark icon after copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
const copyBtns = screen.getAllByTitle('Copy');
|
||||
await user.click(copyBtns[0]);
|
||||
await waitFor(() => {
|
||||
// After copy, icon changes to Check (green). The button should contain an svg with text-green-500
|
||||
const btn = copyBtns[0];
|
||||
const svg = btn.querySelector('svg');
|
||||
expect(svg).toHaveClass('text-green-500');
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-017: cancel button in delete confirm modal closes it without API call', async () => {
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () =>
|
||||
HttpResponse.json({
|
||||
tokens: [
|
||||
{ id: 1, name: 'Cancel Token', token_prefix: 'tk_can', created_at: '2025-01-01T00:00:00.000Z', last_used_at: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
http.delete('/api/auth/mcp-tokens/1', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Cancel Token');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await screen.findByRole('button', { name: /^Cancel$/i });
|
||||
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('This token will stop working immediately. Any MCP client using it will lose access.')).toBeNull();
|
||||
});
|
||||
expect(deleteCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-018: pressing Enter in the token name input triggers creation', async () => {
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/auth/mcp-tokens', () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({
|
||||
token: {
|
||||
id: 1,
|
||||
name: 'enter-test',
|
||||
token_prefix: 'tk_ent',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
raw_token: 'tk_ent...full',
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
await user.type(input, 'enter-test');
|
||||
await user.keyboard('{Enter}');
|
||||
await waitFor(() => {
|
||||
expect(postCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// FE-COMP-MAP-001 to FE-COMP-MAP-017
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import MapSettingsTab from './MapSettingsTab';
|
||||
|
||||
// Mock MapView to avoid Leaflet DOM issues in jsdom
|
||||
vi.mock('../Map/MapView', () => ({
|
||||
MapView: ({ onMapClick }: { onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void }) => (
|
||||
<div data-testid="map-view" onClick={() => onMapClick?.({ latlng: { lat: 51.5, lng: -0.1 } })} />
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({
|
||||
map_tile_url: '',
|
||||
default_lat: 48.8566,
|
||||
default_lng: 2.3522,
|
||||
default_zoom: 10,
|
||||
}),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapSettingsTab', () => {
|
||||
it('FE-COMP-MAP-001: renders without crashing', () => {
|
||||
render(<MapSettingsTab />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-002: shows the Map section title', () => {
|
||||
render(<MapSettingsTab />);
|
||||
expect(screen.getByText('Map')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-003: shows the map template label', () => {
|
||||
render(<MapSettingsTab />);
|
||||
expect(screen.getByText('Map Template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-004: shows latitude and longitude inputs', () => {
|
||||
render(<MapSettingsTab />);
|
||||
expect(screen.getByText('Latitude')).toBeInTheDocument();
|
||||
expect(screen.getByText('Longitude')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-005: latitude input is pre-filled from store settings', () => {
|
||||
render(<MapSettingsTab />);
|
||||
expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-006: longitude input is pre-filled from store settings', () => {
|
||||
render(<MapSettingsTab />);
|
||||
expect(screen.getByDisplayValue('2.3522')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-007: typing in the latitude input updates its displayed value', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MapSettingsTab />);
|
||||
const latInput = screen.getByDisplayValue('48.8566');
|
||||
await user.clear(latInput);
|
||||
await user.type(latInput, '51.5');
|
||||
expect(screen.getByDisplayValue('51.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-008: typing in the longitude input updates its displayed value', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MapSettingsTab />);
|
||||
const lngInput = screen.getByDisplayValue('2.3522');
|
||||
await user.clear(lngInput);
|
||||
await user.type(lngInput, '-0.1');
|
||||
expect(screen.getByDisplayValue('-0.1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-009: tile URL text input is shown', () => {
|
||||
render(<MapSettingsTab />);
|
||||
const tileInput = screen.getByPlaceholderText(/openstreetmap/i);
|
||||
expect(tileInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-010: typing a custom tile URL updates the text input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MapSettingsTab />);
|
||||
const tileInput = screen.getByPlaceholderText(/openstreetmap/i);
|
||||
await user.clear(tileInput);
|
||||
// Escape curly braces so userEvent doesn't treat them as special keys
|
||||
await user.type(tileInput, 'https://custom.tiles/{{z}/{{x}/{{y}.png');
|
||||
expect(screen.getByDisplayValue('https://custom.tiles/{z}/{x}/{y}.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-011: clicking the Save Map button calls updateSettings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: '', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10 }),
|
||||
updateSettings,
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
await user.click(screen.getByText('Save Map'));
|
||||
expect(updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
map_tile_url: expect.any(String),
|
||||
default_lat: expect.any(Number),
|
||||
default_lng: expect.any(Number),
|
||||
default_zoom: expect.any(Number),
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-012: Save Map parses numeric values correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: '', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10 }),
|
||||
updateSettings,
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
await user.click(screen.getByText('Save Map'));
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
map_tile_url: '',
|
||||
default_lat: 48.8566,
|
||||
default_lng: 2.3522,
|
||||
default_zoom: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-013: Save Map button shows spinner while saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockReturnValue(new Promise(() => {}));
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings(),
|
||||
updateSettings,
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
await user.click(screen.getByText('Save Map'));
|
||||
const saveBtn = screen.getByText('Save Map').closest('button')!;
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-014: Save Map error shows a toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockRejectedValue(new Error('Save failed'));
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings(),
|
||||
updateSettings,
|
||||
});
|
||||
render(<><ToastContainer /><MapSettingsTab /></>);
|
||||
await user.click(screen.getByText('Save Map'));
|
||||
await screen.findByText('Save failed');
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-015: clicking the map updates lat/lng state', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MapSettingsTab />);
|
||||
await user.click(screen.getByTestId('map-view'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('51.5')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('-0.1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-016: preset dropdown is rendered', () => {
|
||||
render(<MapSettingsTab />);
|
||||
expect(screen.getByText('Select template...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-017: settings update from store syncs local state', async () => {
|
||||
const { rerender } = render(<MapSettingsTab />);
|
||||
expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument();
|
||||
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ default_lat: 40.0 }),
|
||||
});
|
||||
rerender(<MapSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('40')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user