mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11: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:
@@ -18,6 +18,12 @@ beforeEach(() => {
|
||||
seedStore(usePermissionsStore, {
|
||||
level: 'owner',
|
||||
} as any);
|
||||
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
||||
server.use(
|
||||
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
|
||||
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// FE-PAGE-OAUTH-001 to FE-PAGE-OAUTH-012
|
||||
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 { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||
import { buildUser } from '../../tests/helpers/factories';
|
||||
import OAuthAuthorizePage from './OAuthAuthorizePage';
|
||||
|
||||
// Default OAuth query params
|
||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||
|
||||
function setSearchParams(search: string) {
|
||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
||||
}
|
||||
|
||||
const VALIDATE_OK = {
|
||||
valid: true,
|
||||
client: { name: 'Test App', allowed_scopes: ['trips:read'] },
|
||||
scopes: ['trips:read'],
|
||||
consentRequired: true,
|
||||
loginRequired: false,
|
||||
scopeSelectable: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
setSearchParams(DEFAULT_SEARCH);
|
||||
server.resetHandlers();
|
||||
// Default: authenticated user
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, isLoading: false });
|
||||
// Default validate: consent required
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () => HttpResponse.json(VALIDATE_OK)),
|
||||
http.post('/api/oauth/authorize', () =>
|
||||
HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=abc' })
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('OAuthAuthorizePage', () => {
|
||||
it('FE-PAGE-OAUTH-001: shows loading spinner initially', () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', async () => {
|
||||
await new Promise(() => {}); // never resolves
|
||||
return HttpResponse.json(VALIDATE_OK);
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-002: shows error state when validation fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({
|
||||
valid: false,
|
||||
error: 'invalid_client',
|
||||
error_description: 'Unknown client ID',
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Authorization Error');
|
||||
expect(screen.getByText('Unknown client ID')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-003: shows error state on network error', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Authorization Error');
|
||||
expect(screen.getByText(/Failed to validate/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-004: shows login_required state', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, loginRequired: true, consentRequired: true })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Sign in to continue');
|
||||
expect(screen.getByText('Sign in to TREK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-005: shows client name in login_required state', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, loginRequired: true })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Sign in to continue');
|
||||
expect(screen.getByText(/Test App/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-006: shows consent form with client name and scope list', async () => {
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Test App');
|
||||
expect(screen.getByText('Authorization Request')).toBeInTheDocument();
|
||||
expect(screen.getByText('Approve Access')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deny')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-007: auto-approves when consentRequired is false', async () => {
|
||||
let authorizeCalled = false;
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, consentRequired: false })
|
||||
),
|
||||
http.post('/api/oauth/authorize', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
authorizeCalled = true;
|
||||
expect(body.approved).toBe(true);
|
||||
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=xyz' });
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
// Shows auto-approving spinner
|
||||
await waitFor(() => {
|
||||
expect(authorizeCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-008: clicking Deny sends approved=false to authorize', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.post('/api/oauth/authorize', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?error=access_denied' });
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Deny');
|
||||
await user.click(screen.getByText('Deny'));
|
||||
await waitFor(() => {
|
||||
expect(body.approved).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-009: clicking Approve sends approved=true with selected scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> = {};
|
||||
server.use(
|
||||
http.post('/api/oauth/authorize', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=ok' });
|
||||
})
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Approve Access');
|
||||
await user.click(screen.getByText('Approve Access'));
|
||||
await waitFor(() => {
|
||||
expect(body.approved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-010: shows error when authorize call fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/authorize', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Approve Access');
|
||||
await user.click(screen.getByText('Approve Access'));
|
||||
await screen.findByText('Authorization Error');
|
||||
expect(screen.getByText(/Authorization failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-011: scopeSelectable=true renders checkboxes for scopes', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/authorize/validate', () =>
|
||||
HttpResponse.json({ ...VALIDATE_OK, scopeSelectable: true, scopes: ['trips:read', 'places:read'] })
|
||||
)
|
||||
);
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Choose which permissions to grant');
|
||||
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-PAGE-OAUTH-012: scopeSelectable=false renders read-only scope list', async () => {
|
||||
render(<OAuthAuthorizePage />);
|
||||
await screen.findByText('Permissions requested');
|
||||
// No checkboxes in read-only mode
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -65,8 +65,12 @@ vi.mock('../components/Planner/PlacesSidebar', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const capturedPlaceInspectorProps: { current: Record<string, any> } = { current: {} };
|
||||
vi.mock('../components/Planner/PlaceInspector', () => ({
|
||||
default: () => null,
|
||||
default: (props: Record<string, any>) => {
|
||||
capturedPlaceInspectorProps.current = props;
|
||||
return React.createElement('div', { 'data-testid': 'place-inspector' });
|
||||
},
|
||||
}));
|
||||
|
||||
const capturedDayDetailPanelProps: { current: Record<string, any> } = { current: {} };
|
||||
@@ -232,6 +236,7 @@ beforeEach(() => {
|
||||
capturedTripFormModalProps.current = {};
|
||||
capturedTripMembersModalProps.current = {};
|
||||
capturedFileManagerProps.current = {};
|
||||
capturedPlaceInspectorProps.current = {};
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||
});
|
||||
|
||||
@@ -1334,6 +1339,166 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-046: Invalid session tab resets to plan', () => {
|
||||
it('resets activeTab to "plan" when saved tab is no longer in TRIP_TABS', async () => {
|
||||
// Save a tab id that requires the "memories" addon (disabled by default)
|
||||
sessionStorage.setItem('trip-tab-42', 'memories');
|
||||
seedTripStore({ id: 42 });
|
||||
|
||||
renderPlannerPage(42);
|
||||
|
||||
// The useEffect should detect the invalid tab and reset it
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('trip-tab-42')).toBe('plan');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-047: Desktop PlaceInspector onEdit with selectedAssignment', () => {
|
||||
it('calls onEdit on desktop PlaceInspector with selectedAssignmentId to cover if-branch', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
||||
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
|
||||
|
||||
mockPlaceSelectionState.selectedPlaceId = place.id;
|
||||
mockPlaceSelectionState.selectedAssignmentId = assignment.id;
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
seedStore(useTripStore, {
|
||||
places: [place],
|
||||
assignments: { '99': [assignment] },
|
||||
} as any);
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// onEdit with selectedAssignmentId set — covers lines 795-798 (if branch)
|
||||
await act(async () => {
|
||||
capturedPlaceInspectorProps.current.onEdit?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-048: Mobile PlaceInspector portal renders when isMobile is true', () => {
|
||||
it('renders PlaceInspector in mobile portal and covers mobile callbacks', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Simulate mobile viewport
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||
|
||||
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
||||
|
||||
mockPlaceSelectionState.selectedPlaceId = place.id;
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
seedStore(useTripStore, { places: [place] } as any);
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
// Mobile portal renders the PlaceInspector (lines 830-879)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// onEdit without assignment — covers else branch at line 799
|
||||
await act(async () => {
|
||||
capturedPlaceInspectorProps.current.onEdit?.();
|
||||
});
|
||||
|
||||
// onClose — covers mobile onClose lambda
|
||||
await act(async () => {
|
||||
capturedPlaceInspectorProps.current.onClose?.();
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-049: Mobile sidebar left panel opens via Plan button', () => {
|
||||
it('clicking the mobile Plan button opens the left sidebar portal (lines 882-893)', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The mobile portal buttons are rendered to document.body.
|
||||
// The "Plan" tab button has title="Plan"; the mobile portal button does not.
|
||||
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
|
||||
b => b.textContent === 'Plan' && !b.getAttribute('title'),
|
||||
);
|
||||
|
||||
if (mobilePlanBtn) {
|
||||
await act(async () => { fireEvent.click(mobilePlanBtn); });
|
||||
|
||||
// Mobile sidebar portal renders DayPlanSidebar — now two instances
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('day-plan-sidebar').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Close the mobile sidebar via the X button inside the portal header
|
||||
const closeButtons = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
b => !b.textContent || b.textContent.trim() === '',
|
||||
);
|
||||
if (closeButtons.length > 0) {
|
||||
await act(async () => { fireEvent.click(closeButtons[0]); });
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-050: Mobile sidebar right panel opens via Places button', () => {
|
||||
it('clicking the mobile Places button opens the right sidebar portal (lines 894)', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('places-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// "Places" tab doesn't exist; the mobile portal "Places" button has no title
|
||||
const mobilePlacesBtn = Array.from(document.body.querySelectorAll('button')).find(
|
||||
b => b.textContent === 'Places' && !b.getAttribute('title'),
|
||||
);
|
||||
|
||||
if (mobilePlacesBtn) {
|
||||
await act(async () => { fireEvent.click(mobilePlacesBtn); });
|
||||
|
||||
// PlacesSidebar renders in mobile sidebar portal
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('places-sidebar').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
|
||||
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
Reference in New Issue
Block a user