mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
test: add comprehensive coverage for OAuth scopes, MCP, and core services
Adds new and expanded test suites across client and server to cover the OAuth 2.1 scope system, MCP session manager, collab service, unified memories helpers, OIDC service, budget slice, and OAuth authorize page. Also extends SonarQube coverage exclusions to include bootstrapping files (migrations, scheduler, main.tsx, types.ts) that are not meaningfully testable.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-010
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Failed to load tokens');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ sessions: [] });
|
||||
})
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
client_name: 'Claude Desktop',
|
||||
username: 'alice',
|
||||
scopes: ['trips:read', 'budget:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('trips:read')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
|
||||
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('App');
|
||||
// "+1 more" button should appear
|
||||
const moreBtn = await screen.findByText(/\+1 more/);
|
||||
expect(moreBtn).toBeInTheDocument();
|
||||
await user.click(moreBtn);
|
||||
// After expand, "show less" appears
|
||||
expect(await screen.findByText('show less')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/5', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Revoke Me');
|
||||
|
||||
// Click the revoke (trash) button next to the session
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
|
||||
// Confirmation modal opens
|
||||
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/6', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Error Session');
|
||||
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await screen.findByText('Failed to revoke session');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-020
|
||||
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BudgetPanel from './BudgetPanel';
|
||||
@@ -418,4 +419,80 @@ describe('BudgetPanel', () => {
|
||||
// Grand total card shows 300.00
|
||||
expect(screen.getByText('300.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Read Only Item');
|
||||
// In read-only mode the Delete button should not be visible
|
||||
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Train');
|
||||
// expense_date is rendered as plain text in read-only mode
|
||||
await screen.findByText('2025-06-15');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
|
||||
],
|
||||
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
|
||||
})
|
||||
),
|
||||
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Lunch');
|
||||
// Trigger settlement display
|
||||
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||
await user.click(settlementBtn);
|
||||
await screen.findByText('alice');
|
||||
// Avatar image should be rendered for alice
|
||||
const avatarImg = screen.getAllByRole('img');
|
||||
expect(avatarImg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Snack');
|
||||
// When expense_date is null, the fallback '—' is shown
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-010
|
||||
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
|
||||
// Recent notification shows "just now"
|
||||
expect(screen.getByText('just now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
|
||||
/>
|
||||
);
|
||||
expect(document.querySelector('img')).toBeInTheDocument();
|
||||
expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-012: boolean notification shows Accept and Reject buttons', () => {
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
type: 'boolean',
|
||||
positive_text_key: 'common.yes',
|
||||
negative_text_key: 'common.no',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('No')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-013: clicking Accept calls respondToBoolean with positive', async () => {
|
||||
const user = userEvent.setup();
|
||||
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useInAppNotificationStore, { respondToBoolean });
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
id: 55,
|
||||
type: 'boolean',
|
||||
positive_text_key: 'common.yes',
|
||||
negative_text_key: 'common.no',
|
||||
response: null,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText('Yes'));
|
||||
expect(respondToBoolean).toHaveBeenCalledWith(55, 'positive');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-014: clicking Reject calls respondToBoolean with negative', async () => {
|
||||
const user = userEvent.setup();
|
||||
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useInAppNotificationStore, { respondToBoolean });
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
id: 66,
|
||||
type: 'boolean',
|
||||
positive_text_key: 'common.yes',
|
||||
negative_text_key: 'common.no',
|
||||
response: null,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText('No'));
|
||||
expect(respondToBoolean).toHaveBeenCalledWith(66, 'negative');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-015: navigate notification shows action button', () => {
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
type: 'navigate',
|
||||
navigate_text_key: 'notifications.title',
|
||||
navigate_target: '/trips/1',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
// t('notifications.title') = "Notifications" — the navigate button renders this
|
||||
const navigateBtn = document.querySelector('button[style*="pointer"]') ??
|
||||
Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
|
||||
expect(navigateBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-016: clicking navigate button marks read and navigates', async () => {
|
||||
const user = userEvent.setup();
|
||||
const markRead = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
seedStore(useInAppNotificationStore, { markRead });
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
id: 77,
|
||||
type: 'navigate',
|
||||
navigate_text_key: 'notifications.title',
|
||||
navigate_target: '/trips/1',
|
||||
is_read: 0,
|
||||
})}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
// The navigate button renders t('notifications.title') = "Notifications"
|
||||
const btn = Array.from(document.querySelectorAll('button')).find(
|
||||
b => b.textContent?.includes('Notifications')
|
||||
);
|
||||
expect(btn).toBeTruthy();
|
||||
await user.click(btn!);
|
||||
expect(markRead).toHaveBeenCalledWith(77);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import ScopeGroupPicker from './ScopeGroupPicker';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('ScopeGroupPicker', () => {
|
||||
it('FE-COMP-SCOPE-001: renders scope groups', () => {
|
||||
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
// Several group headers should be visible
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-002: shows Select All button when nothing selected', () => {
|
||||
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-003: Select All calls onChange with all scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const called = onChange.mock.calls[0][0] as string[];
|
||||
expect(called.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-004: shows Deselect All button when all selected', async () => {
|
||||
// First collect all scopes by clicking Select All and capturing the callback
|
||||
const user = userEvent.setup();
|
||||
const captured: string[][] = [];
|
||||
const { rerender } = render(
|
||||
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
const allScopes = captured[0];
|
||||
|
||||
// Now rerender with all scopes selected
|
||||
rerender(<ScopeGroupPicker selected={allScopes} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /deselect all/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-005: Deselect All calls onChange with empty array', async () => {
|
||||
const user = userEvent.setup();
|
||||
const captured: string[][] = [];
|
||||
|
||||
// Get all scopes first
|
||||
const { rerender } = render(
|
||||
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
const allScopes = captured[0];
|
||||
|
||||
const onChange = vi.fn();
|
||||
rerender(<ScopeGroupPicker selected={allScopes} onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: /deselect all/i }));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-006: expanding a group reveals individual scope checkboxes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
|
||||
// Groups are collapsed by default — checkboxes for individual scopes not visible
|
||||
const groupToggles = screen.getAllByRole('button').filter(b =>
|
||||
!b.textContent?.toLowerCase().includes('select all') &&
|
||||
!b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
// Click the first group expand button
|
||||
await user.click(groupToggles[0]);
|
||||
// Individual scope checkboxes should now appear (more than just group-level ones)
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-007: group checkbox selects all scopes in the group', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
|
||||
const groupCheckboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(groupCheckboxes[0]);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const called = onChange.mock.calls[0][0] as string[];
|
||||
expect(called.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-008: individual scope toggle adds/removes that scope', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
|
||||
// Expand first group
|
||||
const groupToggles = screen.getAllByRole('button').filter(b =>
|
||||
!b.textContent?.toLowerCase().includes('select all') &&
|
||||
!b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
await user.click(groupToggles[0]);
|
||||
|
||||
// There are now individual scope checkboxes — click the second one (first is group-level)
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(checkboxes[1]); // individual scope
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-009: count badge shown when some scopes selected in group', () => {
|
||||
// Get any single scope key from the first group via Select All trick + manual slice
|
||||
// We'll just select a scope by triggering group checkbox and passing it in
|
||||
const firstGroupScope = 'trips:read'; // known scope from SCOPE_GROUPS
|
||||
render(<ScopeGroupPicker selected={[firstGroupScope]} onChange={vi.fn()} />);
|
||||
// Count badge like "(1/N)" should be visible
|
||||
expect(screen.getByText(/\(\d+\/\d+\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-018
|
||||
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-032
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import IntegrationsTab from './IntegrationsTab';
|
||||
|
||||
function enableMcp() {
|
||||
@@ -40,6 +41,8 @@ beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
||||
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -379,4 +382,273 @@ describe('IntegrationsTab', () => {
|
||||
await screen.findByText(/Register OAuth 2\.1 clients/i);
|
||||
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-021: OAuth client list renders when clients exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{
|
||||
id: 'client-1',
|
||||
client_id: 'clid-abc',
|
||||
name: 'My OAuth App',
|
||||
redirect_uris: ['http://localhost'],
|
||||
allowed_scopes: ['trips:read', 'places:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('My OAuth App');
|
||||
expect(screen.getByText(/clid-abc/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-022: scope expansion toggle shows more/fewer scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{ id: 'c1', client_id: 'cid', name: 'Big App', redirect_uris: ['http://localhost'], allowed_scopes: scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Big App');
|
||||
// "+2 more" button visible (7 scopes, 5 shown)
|
||||
const moreBtn = screen.getByText(/^\+\d+$/);
|
||||
await user.click(moreBtn);
|
||||
// Show less / collapse button now visible
|
||||
expect(screen.getByText('−')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-023: active OAuth sessions section renders when sessions exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{
|
||||
id: 10,
|
||||
client_name: 'Claude Desktop',
|
||||
scopes: ['trips:read'],
|
||||
access_token_expires_at: '2025-12-31T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.getByText(/trips:read/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-024: Create OAuth Client modal opens and shows presets', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
expect(screen.getByText('Claude.ai')).toBeInTheDocument();
|
||||
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-025: clicking a preset fills form fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
// Presets render as buttons — click "Claude.ai" preset
|
||||
const presetBtns = screen.getAllByRole('button', { name: /Claude\.ai/i });
|
||||
await user.click(presetBtns[0]);
|
||||
// Name field should be filled with 'Claude.ai'
|
||||
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
|
||||
expect((nameInput as HTMLInputElement).value).toBe('Claude.ai');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-026: creating client shows success view with client_id and secret', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
client: {
|
||||
id: 'new-id',
|
||||
client_id: 'clid-new',
|
||||
client_secret: 'secret-value',
|
||||
name: 'Test Client',
|
||||
redirect_uris: ['http://localhost'],
|
||||
allowed_scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
|
||||
await user.type(nameInput, 'Test Client');
|
||||
const uriInput = screen.getByPlaceholderText(/https:\/\/your-app/i);
|
||||
await user.type(uriInput, 'http://localhost');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
// Success view shows client credentials (there may be multiple matches in list + modal)
|
||||
await screen.findAllByText(/clid-new/);
|
||||
const secretEls = await screen.findAllByText(/secret-value/);
|
||||
expect(secretEls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-027: Done button closes created-client modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
client: {
|
||||
id: 'n2',
|
||||
client_id: 'clid-n2',
|
||||
client_secret: 'secret-n2',
|
||||
name: 'TC2',
|
||||
redirect_uris: ['http://localhost'],
|
||||
allowed_scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'TC2');
|
||||
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
await screen.findAllByText(/clid-n2/);
|
||||
// Check the "Client Registered" modal title is visible before Done
|
||||
expect(screen.getByText('Client Registered')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /^Done$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Client Registered')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-028: delete OAuth client confirmation removes client from list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{ id: 'del-1', client_id: 'cid-del', name: 'Delete Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/oauth/clients/del-1', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Client'));
|
||||
// Confirmation modal
|
||||
await screen.findByRole('heading', { name: 'Delete Client' });
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /Delete Client/i });
|
||||
// Modal confirm button is last in DOM (modal renders after list)
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Me')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-029: rotate secret confirmation shows new secret', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{ id: 'rot-1', client_id: 'cid-rot', name: 'Rotate Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/oauth/clients/rot-1/rotate', () =>
|
||||
HttpResponse.json({ client_secret: 'new-rotated-secret' })
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Rotate Me');
|
||||
await user.click(screen.getByTitle('Rotate Secret'));
|
||||
await screen.findByText('Rotate Secret');
|
||||
// Confirm — button text is 'Rotate'
|
||||
const rotateBtns = screen.getAllByRole('button', { name: /^Rotate$/i });
|
||||
await user.click(rotateBtns[rotateBtns.length - 1]);
|
||||
await screen.findByText(/new-rotated-secret/);
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-030: revoke OAuth session removes it from list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/oauth/sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 99, client_name: 'Revoke App', scopes: ['trips:read'], access_token_expires_at: '2025-12-31T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/oauth/sessions/99', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('Revoke App');
|
||||
await user.click(screen.getByText('Revoke'));
|
||||
// Confirmation modal
|
||||
await screen.findByText('Revoke Session');
|
||||
const revokeBtns = screen.getAllByRole('button', { name: /^Revoke$/i });
|
||||
// Modal confirm button is last in DOM
|
||||
await user.click(revokeBtns[revokeBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke App')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-031: Register Client button disabled when name or URI is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
const createBtn = screen.getByRole('button', { name: /Register Client/i });
|
||||
expect(createBtn).toBeDisabled();
|
||||
// Type only name, not URI → still disabled
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Test');
|
||||
expect(createBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
|
||||
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
await screen.findByText(/Failed to register/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user